Compare commits

...

35 Commits

Author SHA1 Message Date
Gaurav Tewari
c4292678a8 feat: ui update 2026-06-16 13:36:43 +05:30
Gaurav Tewari
0b57a1940f fix: make ui good 2026-06-16 12:20:24 +05:30
Gaurav Tewari
f109d40ef6 chore: self review change 2026-06-16 11:43:41 +05:30
Gaurav Tewari
e2fa3387f4 fix: minor fixes 2026-06-16 11:37:01 +05:30
Gaurav Tewari
667cd5ae3b feat: attribute mapping 2026-06-16 10:55:42 +05:30
Gaurav Tewari
1dfff8f43e fix: update model cost drawer 2026-06-16 10:11:08 +05:30
Gaurav Tewari
aaac971606 fix: edit input 2026-06-16 10:10:19 +05:30
Gaurav Tewari
a5a510fb89 refactor: self review changes 2026-06-16 02:10:37 +05:30
Gaurav Tewari
7a7678f4ba feat: llm pricing 2026-06-16 01:46:28 +05:30
Gaurav Tewari
2c42c13a5c feat: update modal 2026-06-16 01:27:56 +05:30
Gaurav Tewari
9af7fd6170 fix: self review changes 2026-06-15 23:44:51 +05:30
Gaurav Tewari
e49d17861c feat: update e2es 2026-06-15 10:16:13 +05:30
Gaurav Tewari
990a4e63af fix: modal cost drawer 2026-06-15 00:21:17 +05:30
Gaurav Tewari
67f56e0be1 fix: minor fixes 2026-06-14 17:27:43 +05:30
Gaurav Tewari
c9a6b26be0 chore: add more featueres 2026-06-12 11:42:23 +05:30
Gaurav Tewari
1dd887f7fd feat: add config page initial draft 2026-06-11 20:30:32 +05:30
Naman Verma
b22eef6a65 feat: v2 list, delete, pin, unpin dashboards api (#11219)
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
* fix: compile error fix

* fix: remove soft delete references

* 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

* feat: method to build postable tags from tags

* fix: build error in Apply method

* chore: remove newline

* fix: remove soft delete references

* fix: build error fix

* chore: embed StorableDashboard in listedRow

* fix: visitor should follow new tag struct

* fix: diff error codes for invalid keys and values

* fix: correct pk in bun model for tag relations

* fix: created and updated by schema

* fix: use coretypes.Kind instead of defining entity type

* fix: singular table name

* chore: remove org ID from tag relation

* feat: foreign key on tag id

* feat: add SyncTags method that covers creation and linking

* fix: remove entity type definition

* fix: fix build errors in dashboard module

* chore: bump migration number

* chore: change entity id to resource id

* fix: add org id filter in all list and delete queries

* fix: remove user auditable

* fix: add ID in tag relation

* fix: fix build error

* fix: fix build error

* chore: bump migration number

* fix: add len check on tags keys and values

* fix: add regex for tags

* chore: remove methods that shouldn't be exposed

* fix: use sync tags in create api

* 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

* 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: 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

* 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: use must new org id

* fix: proper error passage

* chore: rename updateable to updatable

* 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

* 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

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-10 17:34:46 +00:00
Naman Verma
4d3d1ef423 fix: add check for percentile aggregation for non-histogram metrics (#11387)
* fix: add check for percentile aggregation for non-histogram metrics

* test: correct errors pkg in test file

* fix: catch type related errors in querier

* fix: remove comparison related tests

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-10 17:17:26 +00:00
Naman Verma
c775d7e398 chore: add discriminator on kind in perses spec (#11635)
* chore: add discriminator on kind in perses spec

* chore: add discriminator to builder query spec

* chore: update in query builder directly

* docs: add info about discriminator in handler.md

* fix: move back to restrictKindToOneValue

* fix: move back to restrictKindToOneValue
2026-06-10 16:02:44 +00:00
Nikhil Mantri
27603e09d0 feat(infra-monitoring): v2 statefulsets integration tests (#11440)
* chore: updated logic and use centralized function in the module

* chore: filter metric groups

* chore: filter metric groups

* chore: formula correction

* chore: added step flooring note

* chore: comment correction

* chore: comment correction

* chore: removed function

* chore: renamed variables

* chore: added happy test

* chore: added test 2 for accuracy and test 3 for missing metrics check

* chore: added filter test 4

* chore: added 5th test for filterByStatus

* chore: added group by tests

* chore: pagination test added

* chore: added validation tests

* chore: added auth test

* chore: added all tests

* chore: fix for surfacing meta for pods custom group by

* chore: added nodes integration test suite

* chore: namespaces integration tests

* ci: register inframonitoring suite + ruff format 01_hosts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: added integration tests for clusters

* chore: formatting changed

* chore: formatting changed

* chore: formatting changed

* chore: added volumes integration tests

* chore: added deployments

* chore: added tests

* chore: added order by host.name test

* refactor(infra-monitoring): address review comments on hosts integration tests

- inline _post helper at call sites
- combine filter operator tests into parametrized test_hosts_filter
- combine bad attr/grammar tests into parametrized test_hosts_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_hosts_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align pods integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_pods_filter
- combine bad attr/grammar tests into parametrized test_pods_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_pods_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_nodes_filter
- combine bad attr/grammar tests into parametrized test_nodes_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_nodes_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_namespaces_filter
- combine bad attr/grammar tests into parametrized test_namespaces_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_namespaces_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_clusters_filter
- combine bad attr/grammar tests into parametrized test_clusters_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_clusters_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align volumes integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_volumes_filter
- combine bad attr/grammar tests into parametrized test_volumes_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_volumes_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align deployments integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_deployments_filter
- combine bad attr/grammar tests into parametrized test_deployments_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_deployments_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align statefulsets integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_statefulsets_filter
- combine bad attr/grammar tests into parametrized test_statefulsets_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_statefulsets_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts groupby tests into parametrized test

- merge test_hosts_groupby_hostname + test_hosts_groupby_os_type into
  test_hosts_groupby parametrized on (dataset, group key, expected counts/values)
- preserves all assertions incl hostName populated-vs-empty branch coverage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts orderby tests into parametrized test

- merge total_invariant_across_orderby + orderby_correctness + orderby_by_host_name
  into test_hosts_orderby parametrized on (column, record_field) x direction
- each case asserts both the total/len invariant and sortedness; sortedness now
  covered for all metric columns, not just cpu
- single dataset (hosts_orderby.jsonl) + CONTAINS 'order-' guard on all cases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts pagination tests

- fold offset-beyond-total case into the test_hosts_pagination page walk
  (offset K+5 expects 0 records via max(0, min(limit, K-offset)); total
  invariant covers the beyond-total page's total == K)
- single seed instead of two

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): merge hosts happy_path into test_hosts_accuracy

- happy_path and value_accuracy datasets were structurally identical
  (same 4 metrics, same sample counts, 2 hosts); one test now asserts
  shape/contract + exact metric values in a single seed/request
- drop unused hosts_happy_path.jsonl

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine pods integration tests logically

- merge happy_path into test_pods_accuracy (datasets structurally identical);
  drop unused pods_happy_path.jsonl
- merge groupby namespace/deployment into parametrized test_pods_groupby
- merge orderby invariant + correctness + by-pod-name into test_pods_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_pods_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine nodes integration tests logically

- merge happy_path into test_nodes_accuracy; drop unused nodes_happy_path.jsonl
- merge orderby invariant + correctness + by-node-name into test_nodes_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_nodes_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine namespaces integration tests logically

- merge happy_path into test_namespaces_accuracy; drop unused namespaces_happy_path.jsonl
- merge orderby invariant + correctness + by-namespace-name into test_namespaces_orderby
- fold offset-beyond-total into test_namespaces_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine clusters integration tests logically

- merge happy_path into test_clusters_accuracy; drop unused clusters_happy_path.jsonl
- merge orderby invariant + correctness + by-cluster-name into test_clusters_orderby
- fold offset-beyond-total into test_clusters_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine volumes integration tests logically

- merge happy_path into test_volumes_accuracy; drop unused volumes_happy_path.jsonl
- merge orderby invariant + correctness + by-pvc-name into test_volumes_orderby
- fold offset-beyond-total into test_volumes_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine deployments integration tests logically

- merge happy_path into test_deployments_accuracy; drop unused deployments_happy_path.jsonl
- merge orderby invariant + correctness + by-deployment-name into test_deployments_orderby
- fold offset-beyond-total into test_deployments_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine statefulsets integration tests logically

- merge happy_path into test_statefulsets_accuracy; drop unused statefulsets_happy_path.jsonl
- merge orderby invariant + correctness + by-statefulset-name into test_statefulsets_orderby
- fold offset-beyond-total into test_statefulsets_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in hosts filter tests

- regenerate hosts_filter_dataset.jsonl so every host mirrors the acc-h1
  sample pattern from hosts_value_accuracy.jsonl (CI-proven expected values)
- test_hosts_filter now asserts cpu/memory/wait/load15/diskUsage per filtered
  record against FILTER_DATASET_EXPECTED (1e-9), proving filters don't
  distort aggregation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): scope filter expected values inside test_hosts_filter

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in pods filter tests

- regenerate pods_filter_dataset.jsonl so every pod mirrors the acc-p1
  sample pattern from pods_value_accuracy.jsonl (CI-proven expected values)
- test_pods_filter now asserts the 6 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in nodes filter tests

- regenerate nodes_filter_dataset.jsonl so every node mirrors the acc-n1
  sample pattern from nodes_value_accuracy.jsonl (CI-proven expected values)
- test_nodes_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in namespaces filter tests

- regenerate namespaces_filter_dataset.jsonl so every namespace mirrors the
  acc-ns-1 sample pattern (2 pods) from namespaces_value_accuracy.jsonl
- test_namespaces_filter now asserts namespaceCPU/namespaceMemory per record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in clusters filter tests

- regenerate clusters_filter_dataset.jsonl so every cluster mirrors the
  acc-cluster-1 sample pattern (2 nodes) from clusters_value_accuracy.jsonl
- test_clusters_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in volumes filter tests

- regenerate volumes_filter_dataset.jsonl so every PVC mirrors the acc-pvc-1
  sample pattern from volumes_value_accuracy.jsonl (CI-proven expected values)
- test_volumes_filter now asserts all 6 volume fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in deployments filter tests

- regenerate deployments_filter_dataset.jsonl so every deployment mirrors the
  acc-dep-1 sample pattern (2 pods) from deployments_value_accuracy.jsonl
- test_deployments_filter now asserts the 6 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in statefulsets filter tests

- regenerate statefulsets_filter_dataset.jsonl so every statefulset mirrors
  the acc-ss-1 sample pattern (2 pods) from statefulsets_value_accuracy.jsonl
- test_statefulsets_filter now asserts the 6 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes groupby + pod-phase tests with structure

- merge pod_phase_counts_list_mode + _no_pods_on_node into parametrized
  test_nodes_pod_phase_counts ((dataset, node, filter, expected) cases)
- rename groupby_cluster -> test_nodes_groupby, parametrize over
  k8s.node.name + k8s.cluster.name; adds the node-name-in-groupBy branch
  (nodeName populated, condition derived) that hosts/pods both cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces groupby with structure

- rename groupby_cluster -> test_namespaces_groupby, parametrize over
  k8s.namespace.name + k8s.cluster.name; adds the namespace-name-in-groupBy
  branch (namespaceName populated) that hosts/pods/nodes all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters groupby with structure

- rename groupby_cloud_provider -> test_clusters_groupby, parametrize over
  k8s.cluster.name + cloud.provider; adds the cluster-name-in-groupBy branch
  (clusterName populated) that hosts/pods/nodes/namespaces all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align volumes groupby with structure

- rename groupby_namespace -> test_volumes_groupby, parametrize over
  k8s.persistentvolumeclaim.name + k8s.namespace.name; adds the
  pvc-name-in-groupBy branch (persistentVolumeClaimName populated) that the
  other endpoints all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align deployments groupby with structure

- rename groupby_namespace -> test_deployments_groupby, parametrize over
  k8s.deployment.name + k8s.namespace.name; adds the deployment-name-in-groupBy
  branch (deploymentName populated) that the other endpoints all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align statefulsets groupby with structure

- rename groupby_namespace -> test_statefulsets_groupby, parametrize over
  k8s.statefulset.name + k8s.namespace.name; adds the statefulset-name-in-groupBy
  branch (statefulSetName populated) that the other endpoints all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 10:58:22 +00:00
Gaurav Tewari
7b2882abde feat: show recent queries to user (#11523)
* feat: initial commit

* refactor: recent query

* refactor: move all components into one

* refactor: queries

* refactor: self review comments

* refactor: css

* refactor: comments

* refactor: more comments

* feat: add time feature

* refactor : more changes

* chore: remove comments

* refactor: more code

* chore: remove extra commentes

* refactor: store in local storage

* fix: update types

* refactor: more changes

* refactor: import issue

* fix: lint

* chore: update styling

* fix: update query

* feat: use source as well in query key

* refactor: review changes

* refactor: self review changes

* refactor: more changes

* fix: more refactor

* refactor: make code much better

* fix: minor refactor

* refactor: review changes

* refactor: getRecentHook and make logic simple

* chore: drop useSaveRecentQuery from gridcard

* refactor: run save logic on handleRunQuery

* chore: remove space

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-10 10:58:19 +00:00
Nikhil Mantri
30f1c2d92d feat(infra-monitoring): v2 deployments integration tests (#11437)
* chore: updated logic and use centralized function in the module

* chore: filter metric groups

* chore: filter metric groups

* chore: formula correction

* chore: added step flooring note

* chore: comment correction

* chore: comment correction

* chore: removed function

* chore: renamed variables

* chore: added happy test

* chore: added test 2 for accuracy and test 3 for missing metrics check

* chore: added filter test 4

* chore: added 5th test for filterByStatus

* chore: added group by tests

* chore: pagination test added

* chore: added validation tests

* chore: added auth test

* chore: added all tests

* chore: fix for surfacing meta for pods custom group by

* chore: added nodes integration test suite

* chore: namespaces integration tests

* ci: register inframonitoring suite + ruff format 01_hosts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: added integration tests for clusters

* chore: formatting changed

* chore: formatting changed

* chore: formatting changed

* chore: added volumes integration tests

* chore: added deployments

* chore: added order by host.name test

* refactor(infra-monitoring): address review comments on hosts integration tests

- inline _post helper at call sites
- combine filter operator tests into parametrized test_hosts_filter
- combine bad attr/grammar tests into parametrized test_hosts_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_hosts_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align pods integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_pods_filter
- combine bad attr/grammar tests into parametrized test_pods_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_pods_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_nodes_filter
- combine bad attr/grammar tests into parametrized test_nodes_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_nodes_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_namespaces_filter
- combine bad attr/grammar tests into parametrized test_namespaces_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_namespaces_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_clusters_filter
- combine bad attr/grammar tests into parametrized test_clusters_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_clusters_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align volumes integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_volumes_filter
- combine bad attr/grammar tests into parametrized test_volumes_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_volumes_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align deployments integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_deployments_filter
- combine bad attr/grammar tests into parametrized test_deployments_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_deployments_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts groupby tests into parametrized test

- merge test_hosts_groupby_hostname + test_hosts_groupby_os_type into
  test_hosts_groupby parametrized on (dataset, group key, expected counts/values)
- preserves all assertions incl hostName populated-vs-empty branch coverage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts orderby tests into parametrized test

- merge total_invariant_across_orderby + orderby_correctness + orderby_by_host_name
  into test_hosts_orderby parametrized on (column, record_field) x direction
- each case asserts both the total/len invariant and sortedness; sortedness now
  covered for all metric columns, not just cpu
- single dataset (hosts_orderby.jsonl) + CONTAINS 'order-' guard on all cases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts pagination tests

- fold offset-beyond-total case into the test_hosts_pagination page walk
  (offset K+5 expects 0 records via max(0, min(limit, K-offset)); total
  invariant covers the beyond-total page's total == K)
- single seed instead of two

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): merge hosts happy_path into test_hosts_accuracy

- happy_path and value_accuracy datasets were structurally identical
  (same 4 metrics, same sample counts, 2 hosts); one test now asserts
  shape/contract + exact metric values in a single seed/request
- drop unused hosts_happy_path.jsonl

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine pods integration tests logically

- merge happy_path into test_pods_accuracy (datasets structurally identical);
  drop unused pods_happy_path.jsonl
- merge groupby namespace/deployment into parametrized test_pods_groupby
- merge orderby invariant + correctness + by-pod-name into test_pods_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_pods_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine nodes integration tests logically

- merge happy_path into test_nodes_accuracy; drop unused nodes_happy_path.jsonl
- merge orderby invariant + correctness + by-node-name into test_nodes_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_nodes_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine namespaces integration tests logically

- merge happy_path into test_namespaces_accuracy; drop unused namespaces_happy_path.jsonl
- merge orderby invariant + correctness + by-namespace-name into test_namespaces_orderby
- fold offset-beyond-total into test_namespaces_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine clusters integration tests logically

- merge happy_path into test_clusters_accuracy; drop unused clusters_happy_path.jsonl
- merge orderby invariant + correctness + by-cluster-name into test_clusters_orderby
- fold offset-beyond-total into test_clusters_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine volumes integration tests logically

- merge happy_path into test_volumes_accuracy; drop unused volumes_happy_path.jsonl
- merge orderby invariant + correctness + by-pvc-name into test_volumes_orderby
- fold offset-beyond-total into test_volumes_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine deployments integration tests logically

- merge happy_path into test_deployments_accuracy; drop unused deployments_happy_path.jsonl
- merge orderby invariant + correctness + by-deployment-name into test_deployments_orderby
- fold offset-beyond-total into test_deployments_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in hosts filter tests

- regenerate hosts_filter_dataset.jsonl so every host mirrors the acc-h1
  sample pattern from hosts_value_accuracy.jsonl (CI-proven expected values)
- test_hosts_filter now asserts cpu/memory/wait/load15/diskUsage per filtered
  record against FILTER_DATASET_EXPECTED (1e-9), proving filters don't
  distort aggregation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): scope filter expected values inside test_hosts_filter

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in pods filter tests

- regenerate pods_filter_dataset.jsonl so every pod mirrors the acc-p1
  sample pattern from pods_value_accuracy.jsonl (CI-proven expected values)
- test_pods_filter now asserts the 6 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in nodes filter tests

- regenerate nodes_filter_dataset.jsonl so every node mirrors the acc-n1
  sample pattern from nodes_value_accuracy.jsonl (CI-proven expected values)
- test_nodes_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in namespaces filter tests

- regenerate namespaces_filter_dataset.jsonl so every namespace mirrors the
  acc-ns-1 sample pattern (2 pods) from namespaces_value_accuracy.jsonl
- test_namespaces_filter now asserts namespaceCPU/namespaceMemory per record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in clusters filter tests

- regenerate clusters_filter_dataset.jsonl so every cluster mirrors the
  acc-cluster-1 sample pattern (2 nodes) from clusters_value_accuracy.jsonl
- test_clusters_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in volumes filter tests

- regenerate volumes_filter_dataset.jsonl so every PVC mirrors the acc-pvc-1
  sample pattern from volumes_value_accuracy.jsonl (CI-proven expected values)
- test_volumes_filter now asserts all 6 volume fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in deployments filter tests

- regenerate deployments_filter_dataset.jsonl so every deployment mirrors the
  acc-dep-1 sample pattern (2 pods) from deployments_value_accuracy.jsonl
- test_deployments_filter now asserts the 6 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes groupby + pod-phase tests with structure

- merge pod_phase_counts_list_mode + _no_pods_on_node into parametrized
  test_nodes_pod_phase_counts ((dataset, node, filter, expected) cases)
- rename groupby_cluster -> test_nodes_groupby, parametrize over
  k8s.node.name + k8s.cluster.name; adds the node-name-in-groupBy branch
  (nodeName populated, condition derived) that hosts/pods both cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces groupby with structure

- rename groupby_cluster -> test_namespaces_groupby, parametrize over
  k8s.namespace.name + k8s.cluster.name; adds the namespace-name-in-groupBy
  branch (namespaceName populated) that hosts/pods/nodes all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters groupby with structure

- rename groupby_cloud_provider -> test_clusters_groupby, parametrize over
  k8s.cluster.name + cloud.provider; adds the cluster-name-in-groupBy branch
  (clusterName populated) that hosts/pods/nodes/namespaces all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align volumes groupby with structure

- rename groupby_namespace -> test_volumes_groupby, parametrize over
  k8s.persistentvolumeclaim.name + k8s.namespace.name; adds the
  pvc-name-in-groupBy branch (persistentVolumeClaimName populated) that the
  other endpoints all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align deployments groupby with structure

- rename groupby_namespace -> test_deployments_groupby, parametrize over
  k8s.deployment.name + k8s.namespace.name; adds the deployment-name-in-groupBy
  branch (deploymentName populated) that the other endpoints all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 10:35:23 +00:00
Nikhil Mantri
446dd4589f feat(infra-monitoring): v2 volumes integration tests (#11431)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: updated logic and use centralized function in the module

* chore: filter metric groups

* chore: filter metric groups

* chore: formula correction

* chore: added step flooring note

* chore: comment correction

* chore: comment correction

* chore: removed function

* chore: renamed variables

* chore: added happy test

* chore: added test 2 for accuracy and test 3 for missing metrics check

* chore: added filter test 4

* chore: added 5th test for filterByStatus

* chore: added group by tests

* chore: pagination test added

* chore: added validation tests

* chore: added auth test

* chore: added all tests

* chore: fix for surfacing meta for pods custom group by

* chore: added nodes integration test suite

* chore: namespaces integration tests

* ci: register inframonitoring suite + ruff format 01_hosts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: added integration tests for clusters

* chore: formatting changed

* chore: formatting changed

* chore: formatting changed

* chore: added volumes integration tests

* chore: added order by host.name test

* refactor(infra-monitoring): address review comments on hosts integration tests

- inline _post helper at call sites
- combine filter operator tests into parametrized test_hosts_filter
- combine bad attr/grammar tests into parametrized test_hosts_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_hosts_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align pods integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_pods_filter
- combine bad attr/grammar tests into parametrized test_pods_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_pods_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_nodes_filter
- combine bad attr/grammar tests into parametrized test_nodes_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_nodes_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_namespaces_filter
- combine bad attr/grammar tests into parametrized test_namespaces_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_namespaces_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_clusters_filter
- combine bad attr/grammar tests into parametrized test_clusters_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_clusters_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align volumes integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_volumes_filter
- combine bad attr/grammar tests into parametrized test_volumes_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_volumes_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts groupby tests into parametrized test

- merge test_hosts_groupby_hostname + test_hosts_groupby_os_type into
  test_hosts_groupby parametrized on (dataset, group key, expected counts/values)
- preserves all assertions incl hostName populated-vs-empty branch coverage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts orderby tests into parametrized test

- merge total_invariant_across_orderby + orderby_correctness + orderby_by_host_name
  into test_hosts_orderby parametrized on (column, record_field) x direction
- each case asserts both the total/len invariant and sortedness; sortedness now
  covered for all metric columns, not just cpu
- single dataset (hosts_orderby.jsonl) + CONTAINS 'order-' guard on all cases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts pagination tests

- fold offset-beyond-total case into the test_hosts_pagination page walk
  (offset K+5 expects 0 records via max(0, min(limit, K-offset)); total
  invariant covers the beyond-total page's total == K)
- single seed instead of two

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): merge hosts happy_path into test_hosts_accuracy

- happy_path and value_accuracy datasets were structurally identical
  (same 4 metrics, same sample counts, 2 hosts); one test now asserts
  shape/contract + exact metric values in a single seed/request
- drop unused hosts_happy_path.jsonl

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine pods integration tests logically

- merge happy_path into test_pods_accuracy (datasets structurally identical);
  drop unused pods_happy_path.jsonl
- merge groupby namespace/deployment into parametrized test_pods_groupby
- merge orderby invariant + correctness + by-pod-name into test_pods_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_pods_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine nodes integration tests logically

- merge happy_path into test_nodes_accuracy; drop unused nodes_happy_path.jsonl
- merge orderby invariant + correctness + by-node-name into test_nodes_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_nodes_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine namespaces integration tests logically

- merge happy_path into test_namespaces_accuracy; drop unused namespaces_happy_path.jsonl
- merge orderby invariant + correctness + by-namespace-name into test_namespaces_orderby
- fold offset-beyond-total into test_namespaces_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine clusters integration tests logically

- merge happy_path into test_clusters_accuracy; drop unused clusters_happy_path.jsonl
- merge orderby invariant + correctness + by-cluster-name into test_clusters_orderby
- fold offset-beyond-total into test_clusters_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine volumes integration tests logically

- merge happy_path into test_volumes_accuracy; drop unused volumes_happy_path.jsonl
- merge orderby invariant + correctness + by-pvc-name into test_volumes_orderby
- fold offset-beyond-total into test_volumes_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in hosts filter tests

- regenerate hosts_filter_dataset.jsonl so every host mirrors the acc-h1
  sample pattern from hosts_value_accuracy.jsonl (CI-proven expected values)
- test_hosts_filter now asserts cpu/memory/wait/load15/diskUsage per filtered
  record against FILTER_DATASET_EXPECTED (1e-9), proving filters don't
  distort aggregation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): scope filter expected values inside test_hosts_filter

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in pods filter tests

- regenerate pods_filter_dataset.jsonl so every pod mirrors the acc-p1
  sample pattern from pods_value_accuracy.jsonl (CI-proven expected values)
- test_pods_filter now asserts the 6 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in nodes filter tests

- regenerate nodes_filter_dataset.jsonl so every node mirrors the acc-n1
  sample pattern from nodes_value_accuracy.jsonl (CI-proven expected values)
- test_nodes_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in namespaces filter tests

- regenerate namespaces_filter_dataset.jsonl so every namespace mirrors the
  acc-ns-1 sample pattern (2 pods) from namespaces_value_accuracy.jsonl
- test_namespaces_filter now asserts namespaceCPU/namespaceMemory per record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in clusters filter tests

- regenerate clusters_filter_dataset.jsonl so every cluster mirrors the
  acc-cluster-1 sample pattern (2 nodes) from clusters_value_accuracy.jsonl
- test_clusters_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in volumes filter tests

- regenerate volumes_filter_dataset.jsonl so every PVC mirrors the acc-pvc-1
  sample pattern from volumes_value_accuracy.jsonl (CI-proven expected values)
- test_volumes_filter now asserts all 6 volume fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes groupby + pod-phase tests with structure

- merge pod_phase_counts_list_mode + _no_pods_on_node into parametrized
  test_nodes_pod_phase_counts ((dataset, node, filter, expected) cases)
- rename groupby_cluster -> test_nodes_groupby, parametrize over
  k8s.node.name + k8s.cluster.name; adds the node-name-in-groupBy branch
  (nodeName populated, condition derived) that hosts/pods both cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces groupby with structure

- rename groupby_cluster -> test_namespaces_groupby, parametrize over
  k8s.namespace.name + k8s.cluster.name; adds the namespace-name-in-groupBy
  branch (namespaceName populated) that hosts/pods/nodes all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters groupby with structure

- rename groupby_cloud_provider -> test_clusters_groupby, parametrize over
  k8s.cluster.name + cloud.provider; adds the cluster-name-in-groupBy branch
  (clusterName populated) that hosts/pods/nodes/namespaces all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align volumes groupby with structure

- rename groupby_namespace -> test_volumes_groupby, parametrize over
  k8s.persistentvolumeclaim.name + k8s.namespace.name; adds the
  pvc-name-in-groupBy branch (persistentVolumeClaimName populated) that the
  other endpoints all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 09:30:31 +00:00
primus-bot[bot]
c0c9039428 chore(release): bump to v0.128.0 (#11633)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-06-10 06:59:07 +00:00
Nikhil Soni
a014e9c0cb chore(trace-detail): waterfall v3 and v2 cleanup (#11609)
* chore: cleanup waterfall v3 api

We've moved to v4 as part of memory allocation optimisation

* chore: remove select all limit from waterfall request

It was added for testing only and fine tuning the limit value

* chore: update openapi specs

* chore: remove waterfall v2 since we've moved to v4 now
2026-06-10 05:15:36 +00:00
Vinicius Lourenço
b898269ddc chore(api): deprecate any hand-written api call (#11632) 2026-06-10 01:44:03 +00:00
swapnil-signoz
8fdad21a2e chore: bumping integration agent version to v0.0.11 (#11621)
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-09 22:40:30 +00:00
Nikhil Mantri
2e0d25479a feat(infra-monitoring): v2 clusters integration tests (#11430)
* chore: updated logic and use centralized function in the module

* chore: filter metric groups

* chore: filter metric groups

* chore: formula correction

* chore: added step flooring note

* chore: comment correction

* chore: comment correction

* chore: removed function

* chore: renamed variables

* chore: added happy test

* chore: added test 2 for accuracy and test 3 for missing metrics check

* chore: added filter test 4

* chore: added 5th test for filterByStatus

* chore: added group by tests

* chore: pagination test added

* chore: added validation tests

* chore: added auth test

* chore: added all tests

* chore: fix for surfacing meta for pods custom group by

* chore: added nodes integration test suite

* chore: namespaces integration tests

* ci: register inframonitoring suite + ruff format 01_hosts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: added integration tests for clusters

* chore: formatting changed

* chore: formatting changed

* chore: formatting changed

* chore: added order by host.name test

* refactor(infra-monitoring): address review comments on hosts integration tests

- inline _post helper at call sites
- combine filter operator tests into parametrized test_hosts_filter
- combine bad attr/grammar tests into parametrized test_hosts_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_hosts_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align pods integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_pods_filter
- combine bad attr/grammar tests into parametrized test_pods_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_pods_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_nodes_filter
- combine bad attr/grammar tests into parametrized test_nodes_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_nodes_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_namespaces_filter
- combine bad attr/grammar tests into parametrized test_namespaces_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_namespaces_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters integration tests with review feedback

- inline _post helper at call sites
- combine filter operator tests into parametrized test_clusters_filter
- combine bad attr/grammar tests into parametrized test_clusters_filter_invalid
- convert orderby total-invariant nested loop to stacked parametrize
- drop redundant test_clusters_auth (auth covered globally)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts groupby tests into parametrized test

- merge test_hosts_groupby_hostname + test_hosts_groupby_os_type into
  test_hosts_groupby parametrized on (dataset, group key, expected counts/values)
- preserves all assertions incl hostName populated-vs-empty branch coverage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts orderby tests into parametrized test

- merge total_invariant_across_orderby + orderby_correctness + orderby_by_host_name
  into test_hosts_orderby parametrized on (column, record_field) x direction
- each case asserts both the total/len invariant and sortedness; sortedness now
  covered for all metric columns, not just cpu
- single dataset (hosts_orderby.jsonl) + CONTAINS 'order-' guard on all cases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine hosts pagination tests

- fold offset-beyond-total case into the test_hosts_pagination page walk
  (offset K+5 expects 0 records via max(0, min(limit, K-offset)); total
  invariant covers the beyond-total page's total == K)
- single seed instead of two

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): merge hosts happy_path into test_hosts_accuracy

- happy_path and value_accuracy datasets were structurally identical
  (same 4 metrics, same sample counts, 2 hosts); one test now asserts
  shape/contract + exact metric values in a single seed/request
- drop unused hosts_happy_path.jsonl

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine pods integration tests logically

- merge happy_path into test_pods_accuracy (datasets structurally identical);
  drop unused pods_happy_path.jsonl
- merge groupby namespace/deployment into parametrized test_pods_groupby
- merge orderby invariant + correctness + by-pod-name into test_pods_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_pods_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine nodes integration tests logically

- merge happy_path into test_nodes_accuracy; drop unused nodes_happy_path.jsonl
- merge orderby invariant + correctness + by-node-name into test_nodes_orderby
  ((column, record_field) x direction; sortedness now covered for all columns)
- fold offset-beyond-total into test_nodes_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine namespaces integration tests logically

- merge happy_path into test_namespaces_accuracy; drop unused namespaces_happy_path.jsonl
- merge orderby invariant + correctness + by-namespace-name into test_namespaces_orderby
- fold offset-beyond-total into test_namespaces_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): combine clusters integration tests logically

- merge happy_path into test_clusters_accuracy; drop unused clusters_happy_path.jsonl
- merge orderby invariant + correctness + by-cluster-name into test_clusters_orderby
- fold offset-beyond-total into test_clusters_pagination page walk

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in hosts filter tests

- regenerate hosts_filter_dataset.jsonl so every host mirrors the acc-h1
  sample pattern from hosts_value_accuracy.jsonl (CI-proven expected values)
- test_hosts_filter now asserts cpu/memory/wait/load15/diskUsage per filtered
  record against FILTER_DATASET_EXPECTED (1e-9), proving filters don't
  distort aggregation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): scope filter expected values inside test_hosts_filter

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in pods filter tests

- regenerate pods_filter_dataset.jsonl so every pod mirrors the acc-p1
  sample pattern from pods_value_accuracy.jsonl (CI-proven expected values)
- test_pods_filter now asserts the 6 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in nodes filter tests

- regenerate nodes_filter_dataset.jsonl so every node mirrors the acc-n1
  sample pattern from nodes_value_accuracy.jsonl (CI-proven expected values)
- test_nodes_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in namespaces filter tests

- regenerate namespaces_filter_dataset.jsonl so every namespace mirrors the
  acc-ns-1 sample pattern (2 pods) from namespaces_value_accuracy.jsonl
- test_namespaces_filter now asserts namespaceCPU/namespaceMemory per record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(infra-monitoring): assert metric value accuracy in clusters filter tests

- regenerate clusters_filter_dataset.jsonl so every cluster mirrors the
  acc-cluster-1 sample pattern (2 nodes) from clusters_value_accuracy.jsonl
- test_clusters_filter now asserts the 4 CPU/memory fields per filtered record

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align nodes groupby + pod-phase tests with structure

- merge pod_phase_counts_list_mode + _no_pods_on_node into parametrized
  test_nodes_pod_phase_counts ((dataset, node, filter, expected) cases)
- rename groupby_cluster -> test_nodes_groupby, parametrize over
  k8s.node.name + k8s.cluster.name; adds the node-name-in-groupBy branch
  (nodeName populated, condition derived) that hosts/pods both cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align namespaces groupby with structure

- rename groupby_cluster -> test_namespaces_groupby, parametrize over
  k8s.namespace.name + k8s.cluster.name; adds the namespace-name-in-groupBy
  branch (namespaceName populated) that hosts/pods/nodes all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(infra-monitoring): align clusters groupby with structure

- rename groupby_cloud_provider -> test_clusters_groupby, parametrize over
  k8s.cluster.name + cloud.provider; adds the cluster-name-in-groupBy branch
  (clusterName populated) that hosts/pods/nodes/namespaces all cover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 18:51:23 +00:00
swapnil-signoz
73c2c15200 feat: adding support for VMs in Azure integration (#11573)
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: adding supoprt for virtual machines to azure one click integration

* chore: generating openapi spec

* chore: updating dashboard title
2026-06-09 15:12:57 +00:00
Aditya Singh
34203c781f feat(traces): Noz button on trace detail page (#11624)
* feat: add noz ai button

* feat: badge color fix

* feat: revert badge change
2026-06-09 14:34:43 +00:00
swapnil-signoz
1ba9b90855 feat: adding assets and dashboards for azure app services (#11565)
* feat: adding assets and dashboards for azure app services

* refactor: updating metric names

* chore: openapi spec changes

* chore: generating frontend types
2026-06-09 14:30:53 +00:00
SagarRajput-7
927951b67a fix(billing): fix cancel subscription flow failing silently for users without a default mail client (#11622)
* fix(billing): fix cancel subscription flow failing silently for users without a default mail client

* fix(billing): refactor test case

* fix(billing): added log event to the copy template and reopen button

* fix(billing): test case refactor

* fix(billing): use native <a> for mailto retry to maximize cross-browser reliability
2026-06-09 14:18:43 +00:00
Vinicius Lourenço
05ad8d113d chore(vite.config): ensure commits information for sentry (#11623)
* chore(vite.config): ensure commits information is added on new release

* ci(build-staging): enable sentry on staging envs
2026-06-09 13:54:19 +00:00
Aditya Singh
faad1e659a feat: badge color fix (#11625) 2026-06-09 13:37:01 +00:00
Nikhil Soni
e10a9515ef feat(trace-details): add implementation for flamegraph building (part 2) (#11491)
* feat: add config for flamegraph

* Revert "chore: extract out flamegraph building logic for easier review"

This reverts commit f9222c9930.

* chore: remove unwanted absctraction

* refactor: simplify GetSelectedLevels on flamegraph

* refactor: simplify sampling method

* refactor: simplify BFS in getAllLevels method

* chore: fix config key in error messages

* feat: add metrics for flamegraph

* fix: config keys for flamegraph

* fix: handle duplicate spans in flamegraph

* test: add for GetAllLevels and GetSelectedLevel

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 13:29:32 +00:00
242 changed files with 24294 additions and 1888 deletions

View File

@@ -64,6 +64,10 @@ jobs:
run: |
mkdir -p frontend
echo 'CI=1' > frontend/.env
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
echo 'VITE_TUNNEL_URL="${{ secrets.NP_TUNNEL_URL }}"' >> frontend/.env
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env
echo 'VITE_PYLON_APP_ID="${{ secrets.NP_PYLON_APP_ID }}"' >> frontend/.env

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.127.1
image: signoz/signoz:v0.128.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.127.1
image: signoz/signoz:v0.128.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.127.1}
image: signoz/signoz:${VERSION:-v0.128.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.127.1}
image: signoz/signoz:${VERSION:-v0.128.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -1360,6 +1360,8 @@ components:
- sqs
- storageaccountsblob
- cdnprofile
- virtualmachine
- appservice
- containerapp
- aks
type: string
@@ -2494,10 +2496,17 @@ components:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesBuilderQuerySpec:
discriminator:
mapping:
logs: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation'
metrics: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation'
traces: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
propertyName: signal
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation'
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation'
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
type: object
DashboardtypesComparisonOperator:
enum:
- above
@@ -2586,8 +2595,13 @@ components:
type: array
type: object
DashboardtypesDatasourcePlugin:
discriminator:
mapping:
signoz/Datasource: '#/components/schemas/DashboardtypesDatasourcePluginVariantStruct'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesDatasourcePluginVariantStruct'
type: object
DashboardtypesDatasourcePluginKind:
enum:
- signoz/Datasource
@@ -2654,7 +2668,7 @@ components:
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
tags:
items:
$ref: '#/components/schemas/TagtypesPostableTag'
$ref: '#/components/schemas/TagtypesGettableTag'
nullable: true
type: array
updatedAt:
@@ -2731,8 +2745,13 @@ components:
- path
type: object
DashboardtypesLayout:
discriminator:
mapping:
Grid: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec'
type: object
DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec:
properties:
kind:
@@ -2772,6 +2791,11 @@ components:
- solid
- dashed
type: string
DashboardtypesListOrder:
enum:
- asc
- desc
type: string
DashboardtypesListPanelSpec:
properties:
selectFields:
@@ -2779,6 +2803,12 @@ components:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
type: object
DashboardtypesListSort:
enum:
- updated_at
- created_at
- name
type: string
DashboardtypesListVariableSpec:
properties:
allowAllValue:
@@ -2801,6 +2831,134 @@ components:
nullable: true
type: string
type: object
DashboardtypesListableDashboardForUserV2:
properties:
dashboards:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardForUserV2'
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
type: array
total:
format: int64
type: integer
required:
- dashboards
- total
- tags
type: object
DashboardtypesListableDashboardV2:
properties:
dashboards:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2'
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
type: array
total:
format: int64
type: integer
required:
- dashboards
- total
- tags
type: object
DashboardtypesListedDashboardForUserV2:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
id:
type: string
image:
type: string
locked:
type: boolean
name:
type: string
orgId:
type: string
pinned:
type: boolean
schemaVersion:
type: string
source:
$ref: '#/components/schemas/DashboardtypesSource'
spec:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2Spec'
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
type: array
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- orgId
- locked
- source
- schemaVersion
- name
- tags
- spec
- pinned
type: object
DashboardtypesListedDashboardV2:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
id:
type: string
image:
type: string
locked:
type: boolean
name:
type: string
orgId:
type: string
schemaVersion:
type: string
source:
$ref: '#/components/schemas/DashboardtypesSource'
spec:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2Spec'
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
type: array
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- orgId
- locked
- source
- schemaVersion
- name
- tags
- spec
type: object
DashboardtypesListedDashboardV2Spec:
properties:
display:
$ref: '#/components/schemas/CommonDisplay'
type: object
DashboardtypesNumberPanelSpec:
properties:
formatting:
@@ -2832,6 +2990,16 @@ components:
- Panel
type: string
DashboardtypesPanelPlugin:
discriminator:
mapping:
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
@@ -2840,6 +3008,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
- signoz/TimeSeriesPanel
@@ -3018,6 +3187,15 @@ components:
$ref: '#/components/schemas/DashboardtypesQuerySpec'
type: object
DashboardtypesQueryPlugin:
discriminator:
mapping:
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
signoz/Formula: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
signoz/PromQLQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
signoz/TraceOperator: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderTraceOperator'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
@@ -3025,6 +3203,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderTraceOperator'
type: object
DashboardtypesQueryPluginKind:
enum:
- signoz/BuilderQuery
@@ -3279,9 +3458,15 @@ components:
type: boolean
type: object
DashboardtypesVariable:
discriminator:
mapping:
ListVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
type: object
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
properties:
kind:
@@ -3307,10 +3492,17 @@ components:
- spec
type: object
DashboardtypesVariablePlugin:
discriminator:
mapping:
signoz/CustomVariable: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpec'
signoz/DynamicVariable: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpec'
signoz/QueryVariable: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpec'
type: object
DashboardtypesVariablePluginKind:
enum:
- signoz/DynamicVariable
@@ -5513,11 +5705,15 @@ components:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
signal:
$ref: '#/components/schemas/TelemetrytypesSignal'
enum:
- logs
type: string
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation:
properties:
@@ -5564,11 +5760,15 @@ components:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
signal:
$ref: '#/components/schemas/TelemetrytypesSignal'
enum:
- metrics
type: string
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation:
properties:
@@ -5615,11 +5815,15 @@ components:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
signal:
$ref: '#/components/schemas/TelemetrytypesSignal'
enum:
- traces
type: string
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
Querybuildertypesv5QueryBuilderTraceOperator:
properties:
@@ -6724,11 +6928,6 @@ components:
type: object
SpantypesGettableWaterfallTrace:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregationResult'
nullable: true
type: array
endTimestampMillis:
minimum: 0
type: integer
@@ -6816,14 +7015,6 @@ components:
type: object
SpantypesPostableWaterfall:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregation'
nullable: true
type: array
limit:
minimum: 0
type: integer
selectedSpanId:
type: string
uncollapsedSpans:
@@ -7073,6 +7264,16 @@ components:
required:
- references
type: object
TagtypesGettableTag:
properties:
key:
type: string
value:
type: string
required:
- key
- value
type: object
TagtypesPostableTag:
properties:
key:
@@ -13108,6 +13309,82 @@ paths:
tags:
- preferences
/api/v2/dashboards:
get:
deprecated: false
description: 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`).
operationId: ListDashboardsV2
parameters:
- in: query
name: query
schema:
type: string
- in: query
name: sort
schema:
$ref: '#/components/schemas/DashboardtypesListSort'
- in: query
name: order
schema:
$ref: '#/components/schemas/DashboardtypesListOrder'
- in: query
name: limit
schema:
type: integer
- in: query
name: offset
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesListableDashboardV2'
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
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List dashboards (v2)
tags:
- dashboard
post:
deprecated: false
description: This endpoint creates a dashboard in the v2 format that follows
@@ -13166,6 +13443,62 @@ paths:
tags:
- dashboard
/api/v2/dashboards/{id}:
delete:
deprecated: false
description: This endpoint deletes a v2-shape dashboard along with its tag relations.
Locked dashboards are rejected.
operationId: DeleteDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
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 (v2)
tags:
- dashboard
get:
deprecated: false
description: This endpoint returns a v2-shape dashboard.
@@ -20388,6 +20721,196 @@ paths:
summary: Update my user v2
tags:
- users
/api/v2/users/me/dashboards:
get:
deprecated: false
description: 'Same as ListDashboardsV2 but personalized for the calling user:
each dashboard carries the caller''s `pinned` state, and pinned dashboards
float to the top of the requested ordering. Supports the same filter DSL,
sort, order, and pagination.'
operationId: ListDashboardsForUserV2
parameters:
- in: query
name: query
schema:
type: string
- in: query
name: sort
schema:
$ref: '#/components/schemas/DashboardtypesListSort'
- in: query
name: order
schema:
$ref: '#/components/schemas/DashboardtypesListOrder'
- in: query
name: limit
schema:
type: integer
- in: query
name: offset
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesListableDashboardForUserV2'
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
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List dashboards for the current user (v2)
tags:
- dashboard
/api/v2/users/me/dashboards/{id}/pins:
delete:
deprecated: false
description: Removes the pin for the calling user. Idempotent — unpinning a
dashboard that wasn't pinned still returns 204.
operationId: UnpinDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
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
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Unpin a dashboard for the current user (v2)
tags:
- dashboard
put:
deprecated: false
description: Pins the dashboard for the calling user. A user can pin at most
10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned
dashboard is a no-op success.
operationId: PinDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
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
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Pin a dashboard for the current user (v2)
tags:
- dashboard
/api/v2/users/me/factor_password:
put:
deprecated: false
@@ -20679,76 +21202,6 @@ paths:
summary: Get flamegraph view for a trace
tags:
- tracedetail
/api/v3/traces/{traceID}/waterfall:
post:
deprecated: false
description: Returns the waterfall view of spans for a given trace ID with tree
structure, metadata, and windowed pagination
operationId: GetWaterfall
parameters:
- in: path
name: traceID
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableWaterfall'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableWaterfallTrace'
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: Get waterfall view for a trace
tags:
- tracedetail
/api/v4/traces/{traceID}/waterfall:
post:
deprecated: false

View File

@@ -333,6 +333,50 @@ func (Step) JSONSchema() (jsonschema.Schema, error) {
}
```
### `oneOf` with a discriminator
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`).
The parent keeps its `JSONSchemaOneOf()` (the `oneOf` itself) and *additionally* tags it via `PrepareJSONSchema` with the `x-signoz-discriminator` extension; `signoz.attachDiscriminators` then promotes that marker to a real OpenAPI 3 `discriminator` (and strips the duplicate parent properties) after reflection.
```go
// On the parent: expose the oneOf variants...
func (Plugin) JSONSchemaOneOf() []any {
return []any{FooVariant{}}
}
// ...and tag that same oneOf with the discriminator marker.
func (Plugin) PrepareJSONSchema(s *jsonschema.Schema) error {
if s.ExtraProperties == nil {
s.ExtraProperties = map[string]any{}
}
s.ExtraProperties["x-signoz-discriminator"] = map[string]any{
"propertyName": "kind",
"mapping": map[string]string{
"signoz/Foo": "#/components/schemas/FooVariant",
},
}
return nil
}
```
Each variant must declare the discriminator property (`kind`) and mark it `required`.
This produces the following in the generated OpenAPI spec:
```yaml
Plugin:
discriminator:
propertyName: kind
mapping:
signoz/Foo: '#/components/schemas/FooVariant'
oneOf:
- $ref: '#/components/schemas/FooVariant'
type: object
```
Note the discriminator property lives in the variants, not on the parent — the parent is only the union.
## What should I remember?

View File

@@ -229,10 +229,39 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
return module.pkgDashboardModule.DeleteV2(ctx, orgID, id)
})
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error) {
return module.pkgDashboardModule.ListV2(ctx, orgID, params)
}
func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error) {
return module.pkgDashboardModule.ListForUserV2(ctx, orgID, userID, params)
}
func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.PinV2(ctx, orgID, userID, id)
}
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.UnpinV2(ctx, userID, id)
}
func (module *module) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, userID)
}
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

@@ -16,10 +16,11 @@ func newFormatter(dialect schema.Dialect) sqlstore.SQLFormatter {
}
func (f *formatter) JSONExtractString(column, path string) []byte {
var sql []byte
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgres(path)...)
return sql
ops := f.convertJSONPathToPostgres(path)
if len(ops) == 0 {
return f.bunf.AppendIdent(nil, column)
}
return append(f.TextToJsonColumn(column), ops...)
}
func (f *formatter) JSONType(column, path string) []byte {

View File

@@ -18,19 +18,19 @@ func TestJSONExtractString(t *testing.T) {
name: "simple path",
column: "data",
path: "$.field",
expected: `"data"->>'field'`,
expected: `"data"::jsonb->>'field'`,
},
{
name: "nested path",
column: "metadata",
path: "$.user.name",
expected: `"metadata"->'user'->>'name'`,
expected: `"metadata"::jsonb->'user'->>'name'`,
},
{
name: "deeply nested path",
column: "json_col",
path: "$.level1.level2.level3",
expected: `"json_col"->'level1'->'level2'->>'level3'`,
expected: `"json_col"::jsonb->'level1'->'level2'->>'level3'`,
},
{
name: "root path",

View File

@@ -323,3 +323,17 @@ export const AIAssistantPage = Loadable(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);
export const LLMObservabilityModelPricingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
),
);
export const LLMObservabilityAttributeMappingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Attribute Mapping Page" */ 'pages/LLMObservabilityAttributeMapping'
),
);

View File

@@ -22,6 +22,8 @@ import {
IntegrationsDetailsPage,
LicensePage,
ListAllALertsPage,
LLMObservabilityAttributeMappingPage,
LLMObservabilityModelPricingPage,
LiveLogs,
Login,
Logs,
@@ -507,6 +509,20 @@ const routes: AppRoutes[] = [
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
exact: true,
component: LLMObservabilityModelPricingPage,
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
exact: true,
component: LLMObservabilityAttributeMappingPage,
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -5,6 +5,13 @@ import convertObjectIntoParams from 'lib/query/convertObjectIntoParams';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/getTriggered';
/**
* @deprecated Use the generated `useGetAlerts` hook (or `getAlerts` fetcher) from
* `api/generated/services/alerts` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getTriggered = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createEmail';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createMsTeams';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createPager';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createSlack';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createWebhook';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/delete';
/**
* @deprecated Use the generated `useDeleteChannelByID` hook (or `deleteChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const deleteChannel = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editEmail';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editEmail = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editMsTeams';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editMsTeams = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorResponse, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editOpsgenie';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editOpsgenie = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps> | ErrorResponse> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editPager';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editPager = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editSlack';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editSlack = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editWebhook';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editWebhook = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -5,6 +5,13 @@ import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/get';
import { Channels } from 'types/api/channels/getAll';
/**
* @deprecated Use the generated `useGetChannelByID` hook (or `getChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const get = async (props: Props): Promise<SuccessResponseV2<Channels>> => {
try {
const response = await axios.get<PayloadProps>(`/channels/${props.id}`);

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Channels, PayloadProps } from 'types/api/channels/getAll';
/**
* @deprecated Use the generated `useListChannels` hook (or `listChannels` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getAll = async (): Promise<SuccessResponseV2<Channels[]>> => {
try {
const response = await axios.get<PayloadProps>('/channels');

View File

@@ -5,6 +5,13 @@ import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constan
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { CreatePublicDashboardProps } from 'types/api/dashboard/public/create';
/**
* @deprecated Use the generated `useCreatePublicDashboard` hook (or `createPublicDashboard` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const createPublicDashboard = async (
props: CreatePublicDashboardProps,
): Promise<SuccessResponseV2<CreatePublicDashboardProps>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardDataProps, PayloadProps,PublicDashboardDataProps } from 'types/api/dashboard/public/get';
/**
* @deprecated Use the generated `useGetPublicDashboardData` hook (or `getPublicDashboardData` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getPublicDashboardData = async (props: GetPublicDashboardDataProps): Promise<SuccessResponseV2<PublicDashboardDataProps>> => {
try {
const response = await axios.get<PayloadProps>(`/public/dashboards/${props.id}`);

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardMetaProps, PayloadProps,PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
/**
* @deprecated Use the generated `useGetPublicDashboard` hook (or `getPublicDashboard` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getPublicDashboardMeta = async (props: GetPublicDashboardMetaProps): Promise<SuccessResponseV2<PublicDashboardMetaProps>> => {
try {
const response = await axios.get<PayloadProps>(`/dashboards/${props.id}/public`);

View File

@@ -6,6 +6,13 @@ import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardWidgetDataProps } from 'types/api/dashboard/public/getWidgetData';
/**
* @deprecated Use the generated `useGetPublicDashboardWidgetQueryRange` hook (or `getPublicDashboardWidgetQueryRange` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getPublicDashboardWidgetData = async (props: GetPublicDashboardWidgetDataProps): Promise<SuccessResponseV2<MetricRangePayloadV5>> => {
try {
const response = await axios.get(`/public/dashboards/${props.id}/widgets/${props.index}/query_range`, {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps,RevokePublicDashboardAccessProps } from 'types/api/dashboard/public/delete';
/**
* @deprecated Use the generated `useDeletePublicDashboard` hook (or `deletePublicDashboard` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const revokePublicDashboardAccess = async (
props: RevokePublicDashboardAccessProps,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -5,6 +5,13 @@ import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constan
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UpdatePublicDashboardProps } from 'types/api/dashboard/public/update';
/**
* @deprecated Use the generated `useUpdatePublicDashboard` hook (or `updatePublicDashboard` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const updatePublicDashboard = async (
props: UpdatePublicDashboardProps,
): Promise<SuccessResponseV2<UpdatePublicDashboardProps>> => {

View File

@@ -9,6 +9,13 @@ interface ISubstituteVars {
compositeQuery: ICompositeMetricQuery;
}
/**
* @deprecated Use the generated `useReplaceVariables` hook (or `replaceVariables` fetcher) from
* `api/generated/services/querier` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
export const getSubstituteVars = async (
props?: Partial<QueryRangePayloadV5>,
signal?: AbortSignal,

View File

@@ -8,6 +8,12 @@ import { FieldKeyResponse } from 'types/api/dynamicVariables/getFieldKeys';
* Get field keys for a given signal type
* @param signal Type of signal (traces, logs, metrics)
* @param name Optional search text
*
* @deprecated Use the generated `useGetFieldsKeys` hook (or `getFieldsKeys` fetcher) from
* `api/generated/services/fields` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
export const getFieldKeys = async (
signal?: 'traces' | 'logs' | 'metrics',

View File

@@ -11,6 +11,12 @@ import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
* @param name Name of the attribute for which values are being fetched
* @param value Optional search text
* @param existingQuery Optional existing query - across all present dynamic variables
*
* @deprecated Use the generated `useGetFieldsValues` hook (or `getFieldsValues` fetcher) from
* `api/generated/services/fields` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
export const getFieldValues = async (
signal?: 'traces' | 'logs' | 'metrics',

View File

@@ -26,6 +26,7 @@ import type {
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatableDashboardV2DTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeleteDashboardV2PathParameters,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
GetDashboardV2PathParameters,
@@ -35,11 +36,17 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListDashboardsV2200,
ListDashboardsV2Params,
LockDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
PinDashboardV2PathParameters,
RenderErrorResponseDTO,
UnlockDashboardV2PathParameters,
UnpinDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdatePublicDashboardPathParameters,
@@ -641,6 +648,103 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* 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)
*/
export const listDashboardsV2 = (
params?: ListDashboardsV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDashboardsV2200>({
url: `/api/v2/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getListDashboardsV2QueryKey = (
params?: ListDashboardsV2Params,
) => {
return [`/api/v2/dashboards`, ...(params ? [params] : [])] as const;
};
export const getListDashboardsV2QueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListDashboardsV2QueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listDashboardsV2>>> = ({
signal,
}) => listDashboardsV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardsV2QueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardsV2>>
>;
export type ListDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboards (v2)
*/
export function useListDashboardsV2<
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardsV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboards (v2)
*/
export const invalidateListDashboardsV2 = async (
queryClient: QueryClient,
params?: ListDashboardsV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardsV2QueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint creates a dashboard in the v2 format that follows Perses spec.
* @summary Create dashboard (v2)
@@ -724,6 +828,85 @@ export const useCreateDashboardV2 = <
> => {
return useMutation(getCreateDashboardV2MutationOptions(options));
};
/**
* This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.
* @summary Delete dashboard (v2)
*/
export const deleteDashboardV2 = (
{ id }: DeleteDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['deleteDashboardV2'];
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 deleteDashboardV2>>,
{ pathParams: DeleteDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof deleteDashboardV2>>
>;
export type DeleteDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete dashboard (v2)
*/
export const useDeleteDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
> => {
return useMutation(getDeleteDashboardV2MutationOptions(options));
};
/**
* This endpoint returns a v2-shape dashboard.
* @summary Get dashboard (v2)
@@ -1181,3 +1364,260 @@ export const useLockDashboardV2 = <
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};
/**
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
* @summary List dashboards for the current user (v2)
*/
export const listDashboardsForUserV2 = (
params?: ListDashboardsForUserV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDashboardsForUserV2200>({
url: `/api/v2/users/me/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getListDashboardsForUserV2QueryKey = (
params?: ListDashboardsForUserV2Params,
) => {
return [`/api/v2/users/me/dashboards`, ...(params ? [params] : [])] as const;
};
export const getListDashboardsForUserV2QueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsForUserV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListDashboardsForUserV2QueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listDashboardsForUserV2>>
> = ({ signal }) => listDashboardsForUserV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardsForUserV2QueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardsForUserV2>>
>;
export type ListDashboardsForUserV2QueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboards for the current user (v2)
*/
export function useListDashboardsForUserV2<
TData = Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsForUserV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardsForUserV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboards for the current user (v2)
*/
export const invalidateListDashboardsForUserV2 = async (
queryClient: QueryClient,
params?: ListDashboardsForUserV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardsForUserV2QueryKey(params) },
options,
);
return queryClient;
};
/**
* Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.
* @summary Unpin a dashboard for the current user (v2)
*/
export const unpinDashboardV2 = (
{ id }: UnpinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/users/me/dashboards/${id}/pins`,
method: 'DELETE',
signal,
});
};
export const getUnpinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unpinDashboardV2'];
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 unpinDashboardV2>>,
{ pathParams: UnpinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unpinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnpinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unpinDashboardV2>>
>;
export type UnpinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unpin a dashboard for the current user (v2)
*/
export const useUnpinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
return useMutation(getUnpinDashboardV2MutationOptions(options));
};
/**
* Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.
* @summary Pin a dashboard for the current user (v2)
*/
export const pinDashboardV2 = (
{ id }: PinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/users/me/dashboards/${id}/pins`,
method: 'PUT',
signal,
});
};
export const getPinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['pinDashboardV2'];
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 pinDashboardV2>>,
{ pathParams: PinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return pinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type PinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof pinDashboardV2>>
>;
export type PinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Pin a dashboard for the current user (v2)
*/
export const usePinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
return useMutation(getPinDashboardV2MutationOptions(options));
};

View File

@@ -2651,6 +2651,8 @@ export enum CloudintegrationtypesServiceIDDTO {
sqs = 'sqs',
storageaccountsblob = 'storageaccountsblob',
cdnprofile = 'cdnprofile',
virtualmachine = 'virtualmachine',
appservice = 'appservice',
containerapp = 'containerapp',
aks = 'aks',
}
@@ -3493,6 +3495,9 @@ export interface TelemetrytypesTelemetryFieldKeyDTO {
unit?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal {
logs = 'logs',
}
export enum TelemetrytypesSourceDTO {
meter = 'meter',
}
@@ -3548,7 +3553,11 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
signal?: TelemetrytypesSignalDTO;
/**
* @enum logs
* @type string
*/
signal: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -3614,6 +3623,9 @@ export interface Querybuildertypesv5MetricAggregationDTO {
timeAggregation?: MetrictypesTimeAggregationDTO;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal {
metrics = 'metrics',
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO {
/**
* @type array
@@ -3666,7 +3678,11 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
signal?: TelemetrytypesSignalDTO;
/**
* @enum metrics
* @type string
*/
signal: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -3682,6 +3698,9 @@ export interface Querybuildertypesv5TraceAggregationDTO {
expression?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal {
traces = 'traces',
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO {
/**
* @type array
@@ -3734,7 +3753,11 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
signal?: TelemetrytypesSignalDTO;
/**
* @enum traces
* @type string
*/
signal: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -4621,7 +4644,7 @@ export interface DashboardtypesDashboardSpecDTO {
export enum DashboardtypesDatasourcePluginKindDTO {
'signoz/Datasource' = 'signoz/Datasource',
}
export interface TagtypesPostableTagDTO {
export interface TagtypesGettableTagDTO {
/**
* @type string
*/
@@ -4671,7 +4694,7 @@ export interface DashboardtypesGettableDashboardV2DTO {
/**
* @type array,null
*/
tags: TagtypesPostableTagDTO[] | null;
tags: TagtypesGettableTagDTO[] | null;
/**
* @type string
* @format date-time
@@ -4729,6 +4752,157 @@ 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?: CommonDisplayDTO;
}
export interface DashboardtypesListedDashboardForUserV2DTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type boolean
*/
pinned: boolean;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesListedDashboardV2SpecDTO;
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesListableDashboardForUserV2DTO {
/**
* @type array
*/
dashboards: DashboardtypesListedDashboardForUserV2DTO[];
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export interface DashboardtypesListedDashboardV2DTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesListedDashboardV2SpecDTO;
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesListableDashboardV2DTO {
/**
* @type array
*/
dashboards: DashboardtypesListedDashboardV2DTO[];
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
@@ -4745,6 +4919,17 @@ export type DashboardtypesPatchableDashboardV2DTO =
| DashboardtypesJSONPatchOperationDTO[]
| null;
export interface TagtypesPostableTagDTO {
/**
* @type string
*/
key: string;
/**
* @type string
*/
value: string;
}
export interface DashboardtypesPostableDashboardV2DTO {
/**
* @type boolean
@@ -8093,10 +8278,6 @@ export interface SpantypesWaterfallSpanDTO {
}
export interface SpantypesGettableWaterfallTraceDTO {
/**
* @type array,null
*/
aggregations?: SpantypesSpanAggregationResultDTO[] | null;
/**
* @type integer
* @minimum 0
@@ -8216,15 +8397,6 @@ export interface SpantypesPostableTraceAggregationsDTO {
}
export interface SpantypesPostableWaterfallDTO {
/**
* @type array,null
*/
aggregations?: SpantypesSpanAggregationDTO[] | null;
/**
* @type integer
* @minimum 0
*/
limit?: number;
/**
* @type string
*/
@@ -9660,6 +9832,40 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListDashboardsV2Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @description undefined
*/
sort?: DashboardtypesListSortDTO;
/**
* @description undefined
*/
order?: DashboardtypesListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListDashboardsV2200 = {
data: DashboardtypesListableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type CreateDashboardV2201 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
@@ -9668,6 +9874,9 @@ export type CreateDashboardV2201 = {
status: string;
};
export type DeleteDashboardV2PathParameters = {
id: string;
};
export type GetDashboardV2PathParameters = {
id: string;
};
@@ -10500,6 +10709,46 @@ export type GetMyUser200 = {
status: string;
};
export type ListDashboardsForUserV2Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @description undefined
*/
sort?: DashboardtypesListSortDTO;
/**
* @description undefined
*/
order?: DashboardtypesListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListDashboardsForUserV2200 = {
data: DashboardtypesListableDashboardForUserV2DTO;
/**
* @type string
*/
status: string;
};
export type UnpinDashboardV2PathParameters = {
id: string;
};
export type PinDashboardV2PathParameters = {
id: string;
};
export type GetHosts200 = {
data: ZeustypesGettableHostDTO;
/**
@@ -10519,17 +10768,6 @@ export type GetFlamegraph200 = {
status: string;
};
export type GetWaterfallPathParameters = {
traceID: string;
};
export type GetWaterfall200 = {
data: SpantypesGettableWaterfallTraceDTO;
/**
* @type string
*/
status: string;
};
export type GetWaterfallV4PathParameters = {
traceID: string;
};

View File

@@ -16,8 +16,6 @@ import type {
GetFlamegraphPathParameters,
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetWaterfall200,
GetWaterfallPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
@@ -228,105 +226,6 @@ export const useGetFlamegraph = <
> => {
return useMutation(getGetFlamegraphMutationOptions(options));
};
/**
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
* @summary Get waterfall view for a trace
*/
export const getWaterfall = (
{ traceID }: GetWaterfallPathParameters,
spantypesPostableWaterfallDTO?: BodyType<SpantypesPostableWaterfallDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetWaterfall200>({
url: `/api/v3/traces/${traceID}/waterfall`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableWaterfallDTO,
signal,
});
};
export const getGetWaterfallMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
> => {
const mutationKey = ['getWaterfall'];
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 getWaterfall>>,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return getWaterfall(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type GetWaterfallMutationResult = NonNullable<
Awaited<ReturnType<typeof getWaterfall>>
>;
export type GetWaterfallMutationBody =
| BodyType<SpantypesPostableWaterfallDTO>
| undefined;
export type GetWaterfallMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get waterfall view for a trace
*/
export const useGetWaterfall = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
> => {
return useMutation(getGetWaterfallMutationOptions(options));
};
/**
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3
* @summary Get waterfall view for a trace

View File

@@ -5,6 +5,13 @@ import {
QueryKeySuggestionsResponseProps,
} from 'types/api/querySuggestions/types';
/**
* @deprecated Use the generated `useGetFieldsKeys` hook (or `getFieldsKeys` fetcher) from
* `api/generated/services/fields` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
export const getKeySuggestions = (
props: QueryKeyRequestProps,
): Promise<AxiosResponse<QueryKeySuggestionsResponseProps>> => {

View File

@@ -5,6 +5,13 @@ import {
QueryKeyValueSuggestionsResponseProps,
} from 'types/api/querySuggestions/types';
/**
* @deprecated Use the generated `useGetFieldsValues` hook (or `getFieldsValues` fetcher) from
* `api/generated/services/fields` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
export const getValueSuggestions = (
props: QueryKeyValueRequestProps,
): Promise<AxiosResponse<QueryKeyValueSuggestionsResponseProps>> => {

View File

@@ -15,6 +15,13 @@ export interface CreateRoutingPolicyResponse {
message: string;
}
/**
* @deprecated Use the generated `useCreateRoutePolicy` hook (or `createRoutePolicy` fetcher) from
* `api/generated/services/routepolicies` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const createRoutingPolicy = async (
props: CreateRoutingPolicyBody,
): Promise<

View File

@@ -8,6 +8,13 @@ export interface DeleteRoutingPolicyResponse {
message: string;
}
/**
* @deprecated Use the generated `useDeleteRoutePolicyByID` hook (or `deleteRoutePolicyByID` fetcher) from
* `api/generated/services/routepolicies` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const deleteRoutingPolicy = async (
routingPolicyId: string,
): Promise<

View File

@@ -20,6 +20,13 @@ export interface GetRoutingPoliciesResponse {
data?: ApiRoutingPolicy[];
}
/**
* @deprecated Use the generated `useGetAllRoutePolicies` hook (or `getAllRoutePolicies` fetcher) from
* `api/generated/services/routepolicies` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
export const getRoutingPolicies = async (
signal?: AbortSignal,
headers?: Record<string, string>,

View File

@@ -15,6 +15,13 @@ export interface UpdateRoutingPolicyResponse {
message: string;
}
/**
* @deprecated Use the generated `useUpdateRoutePolicy` hook (or `updateRoutePolicy` fetcher) from
* `api/generated/services/routepolicies` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const updateRoutingPolicy = async (
id: string,
props: UpdateRoutingPolicyBody,

View File

@@ -27,7 +27,6 @@ const getTraceV4 = async (
{
selectedSpanId: props.selectedSpanId,
uncollapsedSpans,
limit: 10000,
},
);

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/resetPassword';
/**
* @deprecated Use the generated `useResetPassword` hook (or `resetPassword` fetcher) from
* `api/generated/services/users` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const resetPassword = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UsersProps } from 'types/api/user/inviteUsers';
/**
* @deprecated Use the generated `useCreateBulkInvite` hook (or `createBulkInvite` fetcher) from
* `api/generated/services/users` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const inviteUsers = async (
users: UsersProps,
): Promise<SuccessResponseV2<null>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/setInvite';
/**
* @deprecated Use the generated `useCreateInvite` hook (or `createInvite` fetcher) from
* `api/generated/services/users` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const sendInvite = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {

View File

@@ -5,6 +5,13 @@ import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps } from 'types/api/preferences/list';
import { OrgPreference } from 'types/api/preferences/preference';
/**
* @deprecated Use the generated `useListOrgPreferences` hook (or `listOrgPreferences` fetcher) from
* `api/generated/services/preferences` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const listPreference = async (): Promise<
SuccessResponseV2<OrgPreference[]>
> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Props } from 'types/api/preferences/update';
/**
* @deprecated Use the generated `useUpdateOrgPreference` hook (or `updateOrgPreference` fetcher) from
* `api/generated/services/preferences` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const update = async (props: Props): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put(`/org/preferences/${props.name}`, {

View File

@@ -5,6 +5,13 @@ import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps } from 'types/api/preferences/list';
import { UserPreference } from 'types/api/preferences/preference';
/**
* @deprecated Use the generated `useListUserPreferences` hook (or `listUserPreferences` fetcher) from
* `api/generated/services/preferences` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const list = async (): Promise<SuccessResponseV2<UserPreference[]>> => {
try {
const response = await axios.get<PayloadProps>(`/user/preferences`);

View File

@@ -5,6 +5,13 @@ import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/preferences/get';
import { UserPreference } from 'types/api/preferences/preference';
/**
* @deprecated Use the generated `useGetUserPreference` hook (or `getUserPreference` fetcher) from
* `api/generated/services/preferences` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const get = async (
props: Props,
): Promise<SuccessResponseV2<UserPreference>> => {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Props } from 'types/api/preferences/update';
/**
* @deprecated Use the generated `useUpdateUserPreference` hook (or `updateUserPreference` fetcher) from
* `api/generated/services/preferences` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const update = async (props: Props): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put(`/user/preferences/${props.name}`, {

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { Props, SessionsContext } from 'types/api/v2/sessions/context/get';
/**
* @deprecated Use the generated `useGetSessionContext` hook (or `getSessionContext` fetcher) from
* `api/generated/services/sessions` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const get = async (
props: Props,
): Promise<SuccessResponseV2<SessionsContext>> => {

View File

@@ -3,6 +3,13 @@ import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
/**
* @deprecated Use the generated `useDeleteSession` hook (or `deleteSession` fetcher) from
* `api/generated/services/sessions` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const deleteSession = async (): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.delete<RawSuccessResponse<null>>('/sessions');

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { Props, Token } from 'types/api/v2/sessions/email_password/post';
/**
* @deprecated Use the generated `useCreateSessionByEmailPassword` hook (or `createSessionByEmailPassword` fetcher) from
* `api/generated/services/sessions` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const post = async (props: Props): Promise<SuccessResponseV2<Token>> => {
try {
const response = await axios.post<RawSuccessResponse<Token>>(

View File

@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { Props, Token } from 'types/api/v2/sessions/rotate/post';
/**
* @deprecated Use the generated `useRotateSession` hook (or `rotateSession` fetcher) from
* `api/generated/services/sessions` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const post = async (props: Props): Promise<SuccessResponseV2<Token>> => {
try {
const response = await axios.post<RawSuccessResponse<Token>>(

View File

@@ -8,6 +8,13 @@ import {
QueryRangePayloadV5,
} from 'types/api/v5/queryRange';
/**
* @deprecated Use the generated `useQueryRangeV5` hook (or `queryRangeV5` fetcher) from
* `api/generated/services/querier` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
export const getQueryRangeV5 = async (
props: QueryRangePayloadV5,
version: string,

View File

@@ -1,5 +1,11 @@
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
$dropdown-base-height: 250px;
$recents-header-height: 30px;
$recent-row-height: 36px;
// how many recents are rendered, this caps how tall the dropdown can grow to fit them.
$max-recents-shown: 5;
.code-mirror-where-clause {
width: 100%;
display: flex;
@@ -117,7 +123,23 @@
width: 100% !important;
max-width: 100% !important;
font-family: 'Space Mono', monospace !important;
min-height: 200px !important;
max-height: $dropdown-base-height !important;
overflow-y: auto !important;
// Recents render at the top of the dropdown ahead of regular suggestions.
// At the base max-height, having recents in view would crowd out the
// suggestion list below. This loop grows the dropdown by one row's worth
// of height for every recent present (up to $max-recents-shown), plus the
// section header. `:has(> li:nth-of-type(N) .cm-completionIcon-recent)`
// matches when the Nth child of <ul> is a recent — i.e. there are at
// least N recents visible.
@for $i from 1 through $max-recents-shown {
&:has(> li:nth-of-type(#{$i}) .cm-completionIcon-recent) {
max-height: $dropdown-base-height +
$recents-header-height +
($i * $recent-row-height) !important;
}
}
&::-webkit-scrollbar {
width: 0.3rem;
@@ -133,6 +155,19 @@
background: transparent;
}
completion-section {
display: block;
padding: 10px 12px 6px;
font-size: 10px !important;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--l3-foreground, var(--l2-foreground));
opacity: 0.7;
border-bottom: 0;
background-color: transparent;
}
li {
width: 100% !important;
max-width: 100% !important;
@@ -159,11 +194,78 @@
display: none !important;
}
.cm-completionDetail {
margin-left: auto;
font-style: normal;
font-size: var(--periscope-font-size-small, 11px);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.55;
}
&[aria-selected='true'] {
background: var(--l3-background) !important;
font-weight: 600 !important;
}
}
li:has(.cm-completionIcon-recent) {
&:hover .cm-recent-delete,
&[aria-selected='true'] .cm-recent-delete {
opacity: 1;
}
}
li .cm-completionLabel {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cm-recent-delete {
margin-left: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--l2-foreground);
font-size: 18px;
line-height: 1;
cursor: pointer;
opacity: 0.5;
transition:
opacity 0.12s ease,
background-color 0.12s ease;
&:hover {
opacity: 1;
background: color-mix(in srgb, var(--l2-foreground) 18%, transparent);
}
}
}
&::after {
content: '↓↑ to navigate · ↵ to apply · esc to dismiss';
display: block;
padding: 8px 12px;
border-top: 1px solid var(--l1-border);
font-family: 'Space Mono', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--l2-foreground);
opacity: 0.6;
background: color-mix(in srgb, var(--l1-background) 50%, transparent);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
}

View File

@@ -46,8 +46,15 @@ import {
import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { queryExamples } from './constants';
import { combineInitialAndUserExpression } from './utils';
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
import type { SignalType } from 'types/api/v5/queryRange';
import { queryExamples, SUGGESTIONS_SECTION } from './constants';
import {
combineInitialAndUserExpression,
getRecentOptions,
renderRecentDeleteButton,
} from './utils';
import './QuerySearch.styles.scss';
@@ -1250,6 +1257,41 @@ function QuerySearch({
};
}
const signal = dataSource as SignalType;
function combinedSuggestions(
context: CompletionContext,
): CompletionResult | null {
const fullDoc = context.state.doc.toString();
const recentOptions = getRecentOptions(
getRecentQueries(signal, signalSource ?? ''),
fullDoc,
);
const result = autoSuggestions(context);
const suggestionOptions = (result?.options || []).map((opt) => ({
...opt,
section: SUGGESTIONS_SECTION,
}));
if (recentOptions.length === 0 && suggestionOptions.length === 0) {
return result;
}
if (!result) {
return {
from: 0,
to: fullDoc.length,
options: recentOptions,
};
}
return {
...result,
options: [...recentOptions, ...suggestionOptions],
};
}
// Effect to handle focus state and trigger suggestions
useEffect(() => {
const clearTimeout = toggleSuggestions(10);
@@ -1398,11 +1440,12 @@ function QuerySearch({
})}
extensions={[
autocompletion({
override: [autoSuggestions],
override: [combinedSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,

View File

@@ -1,3 +1,14 @@
export const RECENTS_SECTION = { name: 'Recent searches', rank: 1 } as const;
export const SUGGESTIONS_SECTION = { name: 'Suggestions', rank: 2 } as const;
// Custom CodeMirror Completion.type for recent-query entries. Used to discriminate
// recents from regular autocomplete completions in renderers and event handlers.
export const RECENT_COMPLETION_TYPE = 'recent';
// Max number of recents rendered in the autocomplete dropdown.
// Do change $max-recents-shown: in QuerySearch.styles.scss if you change this.
export const RECENTS_DISPLAY_CAP = 5;
export const queryExamples = [
{
label: 'Basic Query',

View File

@@ -1,3 +1,19 @@
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
import type { Completion } from '@codemirror/autocomplete';
import type { EditorView } from '@uiw/react-codemirror';
import dayjs from 'dayjs';
import { normalizeFilterExpression } from 'lib/recentQueries/normalize';
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
import type { RecentQueryEntry } from 'lib/recentQueries/types';
import type { SignalType } from 'types/api/v5/queryRange';
import 'utils/timeUtils';
import {
RECENT_COMPLETION_TYPE,
RECENTS_DISPLAY_CAP,
RECENTS_SECTION,
} from './constants';
export function combineInitialAndUserExpression(
initial: string,
user: string,
@@ -38,3 +54,106 @@ export function getUserExpressionFromCombined(
}
return c;
}
// Filters and projects a list of recent-query entries into CodeMirror completions.
// Entries are supplied by the caller (typically via the useRecents hook) so this
// function stays pure and React doesn't have to re-subscribe inside CodeMirror's
// autocomplete callback.
export function getRecentOptions(
entries: RecentQueryEntry[],
fullDoc: string,
): Completion[] {
const normalizedDoc = normalizeFilterExpression(fullDoc);
const matches = entries
.filter((e) => {
const normalizedRecent = normalizeFilterExpression(e.filter.expression);
if (normalizedRecent === normalizedDoc) {
return false;
}
if (normalizedDoc === '') {
return true;
}
return normalizedRecent.includes(normalizedDoc);
})
.slice(0, RECENTS_DISPLAY_CAP);
return matches.map((entry, index) => ({
label: entry.filter.expression,
type: RECENT_COMPLETION_TYPE,
// CodeMirror sorts within a section by boost desc, then label asc. The store
// returns entries newest-first, so we mirror that by giving the newest entry
// the highest boost — otherwise CM falls back to alphabetical order and the
// "most recently used" expectation breaks. Stays within the recents section
// because section.rank keeps recents above suggestions regardless of boost.
boost: matches.length - index,
section: RECENTS_SECTION,
detail: dayjs(entry.lastUsedAt).fromNow(),
recentId: entry.id,
recentSignal: entry.signal,
recentSource: entry.source,
apply: (view: EditorView): void => {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: entry.filter.expression,
},
selection: { anchor: entry.filter.expression.length },
});
closeCompletion(view);
},
}));
}
export function renderRecentDeleteButton(
completion: Completion,
_state: unknown,
view: EditorView | null,
): Node | null {
if (completion.type !== RECENT_COMPLETION_TYPE) {
return null;
}
const c = completion as Completion & {
recentId?: string;
recentSignal?: SignalType;
recentSource?: string;
};
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'cm-recent-delete';
btn.setAttribute('aria-label', 'Remove from recent searches');
btn.title = 'Remove from recent searches';
btn.textContent = '×';
queueMicrotask(() => {
if (btn.parentElement) {
btn.parentElement.title = completion.label;
}
});
const stop = (e: Event): void => {
e.preventDefault();
e.stopPropagation();
};
// CodeMirror's autocomplete closes the popup on pointerdown / mousedown outside
// the editor. The delete button lives inside the popup, so we must stop those
// events early — otherwise clicking × would dismiss the dropdown before the
// click handler fires and the entry wouldn't actually get removed.
btn.addEventListener('pointerdown', stop);
btn.addEventListener('mousedown', stop);
btn.addEventListener('click', (e) => {
stop(e);
if (!c.recentId || !c.recentSignal) {
return;
}
recentQueriesStore.remove(c.recentId, c.recentSignal, c.recentSource ?? '');
if (view) {
view.focus();
startCompletion(view);
}
});
return btn;
}

View File

@@ -1,7 +1,9 @@
export const ENVIRONMENT = {
baseURL:
process?.env?.FRONTEND_API_ENDPOINT ||
process?.env?.GITPOD_WORKSPACE_URL?.replace('://', '://8080-') ||
'',
wsURL: process?.env?.WEBSOCKET_API_ENDPOINT || '',
// baseURL:
// process?.env?.FRONTEND_API_ENDPOINT ||
// process?.env?.GITPOD_WORKSPACE_URL?.replace('://', '://8080-') ||
// '',
// wsURL: process?.env?.WEBSOCKET_API_ENDPOINT || '',
baseURL: 'https://app.us.staging.signoz.cloud',
wsURL: 'ws://app.us.staging.signoz.cloud',
};

View File

@@ -91,6 +91,9 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING:
'/llm-observability/settings/attribute-mapping',
} as const;
export default ROUTES;

View File

@@ -70,6 +70,7 @@ export const AIAssistantOpenSource = {
Icon: 'icon',
Shortcut: 'shortcut',
Cmdk: 'cmdk',
TraceDetails: 'trace_details',
} as const;
export type AIAssistantOpenSource =
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];

View File

@@ -67,3 +67,40 @@
background: var(--secondary-background);
border: 1px solid var(--l1-border);
}
.fallbackBody {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.fallbackHint {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--l2-foreground);
margin: 0;
}
.fallbackEmail {
font-size: var(--paragraph-base-500-font-size);
font-weight: var(--paragraph-base-500-font-weight);
color: var(--l1-foreground);
word-break: break-all;
}
.fallbackActions {
display: flex;
gap: var(--spacing-3);
flex-wrap: wrap;
padding-top: var(--padding-4);
}
.retryLink {
box-sizing: border-box;
text-decoration: none;
&:hover {
text-decoration: none;
}
}

View File

@@ -9,7 +9,37 @@ jest.mock('utils/basePath', () => ({
getBaseUrl: (): string => 'https://test.signoz.io',
}));
function mockMailto(): {
mockClick: jest.Mock;
appendSpy: jest.SpyInstance;
removeSpy: jest.SpyInstance;
} {
const mockClick = jest.fn();
const realCreateElement = document.createElement.bind(document);
// Create a real anchor so JSDOM's appendChild/removeChild accept it.
// Override its click() so no navigation occurs.
jest
.spyOn(document, 'createElement')
.mockImplementation((tag: string, options?: ElementCreationOptions) => {
if (tag === 'a') {
const anchor = realCreateElement('a') as HTMLAnchorElement;
anchor.click = mockClick;
return anchor;
}
return realCreateElement(tag, options);
});
const appendSpy = jest.spyOn(document.body, 'appendChild');
const removeSpy = jest.spyOn(document.body, 'removeChild');
return { mockClick, appendSpy, removeSpy };
}
describe('CancelSubscriptionBanner', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('renders banner with title and subtitle', () => {
render(<CancelSubscriptionBanner />);
expect(
@@ -35,12 +65,10 @@ describe('CancelSubscriptionBanner', () => {
screen.getByText(/Cancelling your subscription would stop your data/i),
).toBeInTheDocument();
expect(screen.getByText(/Type/i)).toBeInTheDocument();
expect(
screen.getByPlaceholderText(/Enter the word cancel/i),
).toBeInTheDocument();
expect(screen.getByTestId('cancel-confirm-input')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /cancel subscription/i }),
screen.getByTestId('cancel-subscription-confirm-btn'),
).toBeInTheDocument();
});
@@ -52,12 +80,10 @@ describe('CancelSubscriptionBanner', () => {
screen.getByRole('button', { name: /cancel subscription/i }),
);
const confirmButton = screen.getByRole('button', {
name: /cancel subscription/i,
});
const confirmButton = screen.getByTestId('cancel-subscription-confirm-btn');
expect(confirmButton).toBeDisabled();
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
const input = screen.getByTestId('cancel-confirm-input');
await user.type(input, 'canc');
expect(confirmButton).toBeDisabled();
@@ -73,7 +99,7 @@ describe('CancelSubscriptionBanner', () => {
screen.getByRole('button', { name: /cancel subscription/i }),
);
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
const input = screen.getByTestId('cancel-confirm-input');
await user.type(input, 'cancel');
await user.click(screen.getByRole('button', { name: /go back/i }));
@@ -84,19 +110,11 @@ describe('CancelSubscriptionBanner', () => {
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
expect(screen.getByPlaceholderText(/Enter the word cancel/i)).toHaveValue('');
expect(screen.getByTestId('cancel-confirm-input')).toHaveValue('');
});
it('sends mailto to cloud-support with correct subject after typing "cancel"', async () => {
const realCreateElement = document.createElement.bind(document);
const mockClick = jest.fn();
const mockAnchor = { href: '', click: mockClick };
jest.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'a') {
return mockAnchor as unknown as HTMLAnchorElement;
}
return realCreateElement(tag);
});
it('fires mailto via DOM-attached anchor and shows fallback view after confirming', async () => {
const { mockClick, appendSpy, removeSpy } = mockMailto();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
@@ -104,18 +122,85 @@ describe('CancelSubscriptionBanner', () => {
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'cancel');
const appendedAnchor = appendSpy.mock.calls
.map(([node]) => node)
.find(
(node): node is HTMLAnchorElement =>
node instanceof HTMLAnchorElement && node.href.startsWith('mailto:'),
);
expect(appendedAnchor).toBeDefined();
expect(mockClick).toHaveBeenCalledTimes(1);
expect(removeSpy.mock.calls.some(([node]) => node === appendedAnchor)).toBe(
true,
);
expect(
screen.getByText(/An email draft has been opened/i),
).toBeInTheDocument();
expect(screen.getByText('cloud-support@signoz.io')).toBeInTheDocument();
expect(screen.getByTestId('copy-email-template-btn')).toBeInTheDocument();
expect(screen.getByTestId('retry-mailto-btn')).toBeInTheDocument();
});
it('copies email template to clipboard when Copy button is clicked', async () => {
mockMailto();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
expect(mockAnchor.href).toContain('mailto:cloud-support@signoz.io');
expect(mockAnchor.href).toContain('Cancel%20My%20SigNoz%20Subscription');
expect(mockClick).toHaveBeenCalledTimes(1);
await user.click(screen.getByTestId('copy-email-template-btn'));
jest.restoreAllMocks();
await waitFor(() =>
expect(screen.getByTestId('copy-email-template-btn')).toHaveTextContent(
'Copied!',
),
);
});
it('retry link is a native anchor with correct mailto href in fallback view', async () => {
mockMailto();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
const retryLink = screen.getByTestId('retry-mailto-btn');
expect(retryLink.tagName).toBe('A');
expect(retryLink).toHaveAttribute(
'href',
expect.stringContaining('mailto:cloud-support@signoz.io'),
);
});
it('closes fallback view when Close is clicked and resets state', async () => {
mockMailto();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
await user.click(screen.getByRole('button', { name: /close/i }));
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
});
});

View File

@@ -1,27 +1,100 @@
import { useState } from 'react';
import { SolidInfoCircle, Undo2, X } from '@signozhq/icons';
import { useEffect, useRef, useState } from 'react';
import {
CircleCheck,
Copy,
MailOpen,
SolidInfoCircle,
Undo2,
X,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import { pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useCopyToClipboard } from 'react-use';
import { getBaseUrl } from 'utils/basePath';
import styles from './CancelSubscriptionBanner.module.scss';
import { Color } from '@signozhq/design-tokens';
import styles from './CancelSubscriptionBanner.module.scss';
const SUPPORT_EMAIL = 'cloud-support@signoz.io';
const MAX_MAILTO_URI_LENGTH = 1800;
type DialogView = 'confirm' | 'fallback';
function buildEmailBody(orgName: string, userEmail: string): string {
return [
'Hi SigNoz Team,',
'',
'I would like to cancel my SigNoz Cloud subscription.',
'Please find my account details below.',
'',
'Account Details:',
` • SigNoz URL: ${getBaseUrl()}`,
...(orgName ? [` • Organization: ${orgName}`] : []),
` • Account Email: ${userEmail}`,
'',
'Reason for Cancellation:',
'[Please share the reason for cancellation]',
'',
'Additional feedback (optional):',
'[Any other feedback]',
'',
'Regards,',
'[user name or team name]',
].join('\n');
}
function buildMailtoUri(orgName: string, userEmail: string): string {
const subject = encodeURIComponent('Cancel My SigNoz Subscription');
const body = encodeURIComponent(buildEmailBody(orgName, userEmail));
const full = `mailto:${SUPPORT_EMAIL}?subject=${subject}&body=${body}`;
if (full.length <= MAX_MAILTO_URI_LENGTH) {
return full;
}
const shortBody = encodeURIComponent(
'Hi SigNoz Team,\n\nI would like to cancel my SigNoz Cloud subscription.\nPlease find my account details and reason for cancellation below.\n\n[Your details here]\n\nRegards,',
);
return `mailto:${SUPPORT_EMAIL}?subject=${subject}&body=${shortBody}`;
}
function openMailto(uri: string): void {
const link = document.createElement('a');
link.href = uri;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function CancelSubscriptionBanner(): JSX.Element {
const [open, setOpen] = useState(false);
const [dialogView, setDialogView] = useState<DialogView | null>(null);
const [confirmText, setConfirmText] = useState('');
const [copied, setCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { user, org } = useAppContext();
useEffect(
() => (): void => {
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
},
[],
);
const orgName = org?.[0]?.displayName ?? '';
const userEmail = user?.email ?? '';
const handleOpenCancelDialog = (): void => {
void logEvent('Billing : Cancel Subscription Clicked', {
user: pick(user, ['email', 'displayName', 'role', 'organization']),
role: user?.role,
});
setOpen(true);
setDialogView('confirm');
};
const handleContactSupport = (): void => {
@@ -29,43 +102,41 @@ function CancelSubscriptionBanner(): JSX.Element {
user: pick(user, ['email', 'displayName', 'role', 'organization']),
role: user?.role,
});
const subject = encodeURIComponent('Cancel My SigNoz Subscription');
const orgName = org?.[0]?.displayName ?? '';
const body = encodeURIComponent(
[
'Hi SigNoz Team,',
'',
'I would like to cancel my SigNoz Cloud subscription.',
'Please find my account details below.',
'',
'Account Details:',
` • SigNoz URL: ${getBaseUrl()}`,
...(orgName ? [` • Organization: ${orgName}`] : []),
` • Account Email: ${user?.email ?? ''}`,
'',
'Reason for Cancellation:',
'[Please share the reason for cancellation]',
'',
'Additional feedback (optional):',
'[Any other feedback]',
'',
'Regards,',
'[user name or team name]',
].join('\n'),
);
const link = document.createElement('a');
link.href = `mailto:cloud-support@signoz.io?subject=${subject}&body=${body}`;
link.click();
setOpen(false);
openMailto(buildMailtoUri(orgName, userEmail));
setConfirmText('');
setDialogView('fallback');
};
const handleCopyTemplate = (): void => {
void logEvent('Billing : Cancel Subscription Email Template Copied', {
user: pick(user, ['email', 'displayName', 'role', 'organization']),
role: user?.role,
});
copyToClipboard(buildEmailBody(orgName, userEmail));
setCopied(true);
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
};
const handleRetryMailto = (): void => {
void logEvent('Billing : Cancel Subscription Email Client Reopened', {
user: pick(user, ['email', 'displayName', 'role', 'organization']),
role: user?.role,
});
};
const handleClose = (): void => {
setOpen(false);
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
setDialogView(null);
setConfirmText('');
setCopied(false);
};
const footer = (
const confirmFooter = (
<>
<Button
variant="solid"
@@ -81,12 +152,19 @@ function CancelSubscriptionBanner(): JSX.Element {
prefix={<X size={14} />}
disabled={confirmText !== 'cancel'}
onClick={handleContactSupport}
data-testid="cancel-subscription-confirm-btn"
>
Cancel subscription
</Button>
</>
);
const fallbackFooter = (
<Button variant="solid" color="secondary" onClick={handleClose}>
Close
</Button>
);
return (
<>
<div className={styles.banner}>
@@ -111,27 +189,67 @@ function CancelSubscriptionBanner(): JSX.Element {
</Button>
</div>
<DialogWrapper
open={open}
open={dialogView !== null}
onOpenChange={handleClose}
title="Cancel your subscription?"
width="narrow"
showCloseButton={false}
footer={footer}
footer={dialogView === 'confirm' ? confirmFooter : fallbackFooter}
>
<div className={styles.dialogBody}>
<p className={styles.dialogDescription}>
Cancelling your subscription would stop your data from being ingested to
SigNoz. All the data that has been already sent will also be deleted.
</p>
<p className={styles.dialogConfirmLabel}>
Type <code>cancel</code> to confirm the cancellation.
</p>
<Input
placeholder="Enter the word cancel..."
value={confirmText}
onChange={(e): void => setConfirmText(e.target.value)}
/>
</div>
{dialogView === 'confirm' && (
<div className={styles.dialogBody}>
<p className={styles.dialogDescription}>
Cancelling your subscription would stop your data from being ingested to
SigNoz. All the data that has been already sent will also be deleted.
</p>
<p className={styles.dialogConfirmLabel}>
Type <code>cancel</code> to confirm the cancellation.
</p>
<Input
placeholder="Enter the word cancel..."
value={confirmText}
onChange={(e): void => setConfirmText(e.target.value)}
data-testid="cancel-confirm-input"
/>
</div>
)}
{dialogView === 'fallback' && (
<div className={styles.fallbackBody}>
<p className={styles.fallbackHint}>
An email draft has been opened. If it did not open, send your
cancellation request directly to:
</p>
<span className={styles.fallbackEmail}>{SUPPORT_EMAIL}</span>
<div className={styles.fallbackActions}>
<Button
variant="outlined"
color="secondary"
prefix={copied ? <CircleCheck size={14} /> : <Copy size={14} />}
onClick={handleCopyTemplate}
data-testid="copy-email-template-btn"
>
{copied ? 'Copied!' : 'Copy email template'}
</Button>
<Button
asChild
variant="outlined"
color="secondary"
data-testid="retry-mailto-btn"
>
<a
href={buildMailtoUri(orgName, userEmail)}
onClick={handleRetryMailto}
className={styles.retryLink}
target="_blank"
rel="noopener noreferrer"
>
<MailOpen size={14} />
Reopen email client
</a>
</Button>
</div>
</div>
)}
</DialogWrapper>
</>
);

View File

@@ -0,0 +1,52 @@
import { Button } from '@signozhq/ui/button';
interface AttributeMappingHeaderProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingHeader({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingHeaderProps): JSX.Element {
return (
<header className="page-header">
<div className="page-header__title">
<h1>Attribute Mapping</h1>
<p>Configure source-to-target attribute remapping for LLM traces</p>
</div>
<div className="page-header__actions">
{isDirty && (
<span className="page-header__unsaved" data-testid="unsaved-changes">
Unsaved changes
</span>
)}
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={!isDirty || isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!isDirty || isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</header>
);
}
export default AttributeMappingHeader;

View File

@@ -0,0 +1,90 @@
import { Button } from '@signozhq/ui/button';
import { Plus, X } from '@signozhq/icons';
import KeySearchInput from './KeySearchInput';
import { FieldContextValue } from './types';
interface ConditionKeyListProps {
label: string;
labelHint?: string;
keys: string[];
placeholder: string;
addLabel: string;
testIdPrefix: string;
fieldContext: FieldContextValue;
onChange: (keys: string[]) => void;
}
// Editor for one list of condition keys (the group's span-attribute or
// resource gating keys). Substring "contains" match, order irrelevant.
function ConditionKeyList({
label,
labelHint,
keys,
placeholder,
addLabel,
testIdPrefix,
fieldContext,
onChange,
}: ConditionKeyListProps): JSX.Element {
const updateKey = (index: number, value: string): void => {
onChange(keys.map((key, i) => (i === index ? value : key)));
};
const addKey = (): void => {
onChange([...keys, '']);
};
const removeKey = (index: number): void => {
onChange(keys.filter((_, i) => i !== index));
};
return (
<div className="group-form__field">
<span className="group-form__label">
{label}
{labelHint && <span className="group-form__label-hint"> {labelHint}</span>}
</span>
{keys.length > 0 && (
<div className="group-form__keys">
{keys.map((key, index) => (
// eslint-disable-next-line react/no-array-index-key
<div className="group-form__key" key={index}>
<KeySearchInput
className="group-form__key-input"
placeholder={placeholder}
value={key}
fieldContext={fieldContext}
onChange={(next): void => updateKey(index, next)}
testId={`${testIdPrefix}-${index}`}
/>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Remove key"
onClick={(): void => removeKey(index)}
testId={`${testIdPrefix}-remove-${index}`}
>
<X size={14} />
</Button>
</div>
))}
</div>
)}
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
onClick={addKey}
testId={`${testIdPrefix}-add`}
>
{addLabel}
</Button>
</div>
);
}
export default ConditionKeyList;

View File

@@ -0,0 +1,76 @@
.group-form {
display: flex;
flex-direction: column;
gap: 20px;
padding: 4px 0;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
&--row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
&__label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-400);
}
&__label-hint {
font-weight: 400;
text-transform: none;
letter-spacing: normal;
}
&__hint {
font-size: 12px;
color: var(--bg-vanilla-400);
}
&__keys {
display: flex;
flex-direction: column;
gap: 8px;
}
&__key {
display: flex;
align-items: center;
gap: 6px;
}
&__key-input {
flex: 1;
font-family: 'Geist Mono', monospace;
}
&__error {
padding: 10px 12px;
border-radius: 4px;
background: rgba(229, 72, 77, 0.1);
color: var(--bg-cherry-400);
font-size: 13px;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
}
&__footer-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
}

View File

@@ -0,0 +1,148 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Trash2 } from '@signozhq/icons';
import ConditionKeyList from './ConditionKeyList';
import { FieldContext, GroupDraft, MapperDraftMode } from './types';
import { isGroupDraftValid } from './utils';
import './GroupFormDrawer.styles.scss';
interface GroupFormDrawerProps {
isOpen: boolean;
mode: MapperDraftMode;
draft: GroupDraft;
setDraft: (next: GroupDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
}
function GroupFormDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
}: GroupFormDrawerProps): JSX.Element {
const isEdit = mode === 'edit';
const isValid = isGroupDraftValid(draft);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
title={isEdit ? 'Edit group' : 'New group'}
subTitle="A group gates which spans its mappings run on"
width="wide"
testId="group-form-drawer"
footer={
<div className="group-form__footer">
{isEdit && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
disabled={isDeleting}
testId="group-form-delete"
>
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
)}
<div className="group-form__footer-actions">
<Button
variant="ghost"
color="secondary"
onClick={onClose}
testId="group-form-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
disabled={!isValid || isSaving}
testId="group-form-save"
>
{/* eslint-disable-next-line no-nested-ternary */}
{isSaving ? 'Saving…' : isEdit ? 'Save group' : 'Create group'}
</Button>
</div>
</div>
}
>
<div className="group-form">
<div className="group-form__field">
<span className="group-form__label">Group name</span>
<Input
placeholder="e.g. OpenAI gateway"
value={draft.name}
onChange={(event): void =>
setDraft({ ...draft, name: event.target.value })
}
testId="group-form-name"
/>
</div>
<div className="group-form__field group-form__field--row">
<span className="group-form__label">Enabled</span>
<Switch
value={draft.enabled}
onChange={(checked): void => setDraft({ ...draft, enabled: checked })}
testId="group-form-enabled"
/>
</div>
<ConditionKeyList
label="Condition · span attribute keys"
labelHint="· runs when a span attribute key contains any of these"
keys={draft.attributes}
placeholder="e.g. gen_ai."
addLabel="Add attribute key"
testIdPrefix="group-form-attribute"
fieldContext={FieldContext.attribute}
onChange={(attributes): void => setDraft({ ...draft, attributes })}
/>
<ConditionKeyList
label="Condition · resource keys"
labelHint="· or when a resource key contains any of these"
keys={draft.resource}
placeholder="e.g. service.name"
addLabel="Add resource key"
testIdPrefix="group-form-resource"
fieldContext={FieldContext.resource}
onChange={(resource): void => setDraft({ ...draft, resource })}
/>
<span className="group-form__hint">
Leave both empty to run this group on every span.
</span>
{saveError && (
<div className="group-form__error" role="alert">
{saveError}
</div>
)}
</div>
</DrawerWrapper>
);
}
export default GroupFormDrawer;

View File

@@ -0,0 +1,10 @@
interface IndexBadgeProps {
index: number;
}
// Small positional badge mirroring the Pipelines list ordering chip.
function IndexBadge({ index }: IndexBadgeProps): JSX.Element {
return <span className="am-index-badge">{index + 1}</span>;
}
export default IndexBadge;

View File

@@ -0,0 +1,51 @@
.key-search {
position: relative;
flex: 1;
min-width: 0;
&__dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 20;
min-width: 100%;
width: max-content;
max-width: 420px;
max-height: 240px;
overflow-x: hidden;
overflow-y: auto;
padding: 4px;
border: 1px solid var(--bg-slate-400, rgba(255, 255, 255, 0.12));
border-radius: 6px;
background: var(--bg-ink-400, #121317);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
&--empty {
padding: 8px 10px;
font-size: 12px;
color: var(--bg-vanilla-400);
}
}
&__option {
display: block;
width: 100%;
max-width: 100%;
padding: 6px 10px;
border: none;
border-radius: 3px;
background: transparent;
color: var(--bg-vanilla-100);
font-family: 'Geist Mono', monospace;
font-size: 12px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
&:hover {
background: var(--bg-slate-400, rgba(255, 255, 255, 0.08));
}
}
}

View File

@@ -0,0 +1,112 @@
import { useMemo, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import useDebounce from 'hooks/useDebounce';
import { FieldContext, FieldContextValue } from './types';
import './KeySearchInput.styles.scss';
const SUGGESTION_LIMIT = 50;
const DEBOUNCE_MS = 300;
interface KeySearchInputProps {
value: string;
fieldContext: FieldContextValue;
placeholder?: string;
className?: string;
disabled?: boolean;
testId?: string;
onChange: (value: string) => void;
}
// Maps the mapper's attribute/resource context to the fields-endpoint context.
function toFieldsContext(
context: FieldContextValue,
): TelemetrytypesFieldContextDTO {
return context === FieldContext.resource
? TelemetrytypesFieldContextDTO.resource
: TelemetrytypesFieldContextDTO.attribute;
}
// Free-text input with span/resource key suggestions from /api/v1/fields/keys
// (signal=traces). Typing keeps the custom value; suggestions are assistive.
function KeySearchInput({
value,
fieldContext,
placeholder,
className,
disabled,
testId,
onChange,
}: KeySearchInputProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const debouncedSearch = useDebounce(value, DEBOUNCE_MS);
const { data, isFetching } = useGetFieldsKeys(
{
signal: TelemetrytypesSignalDTO.traces,
fieldContext: toFieldsContext(fieldContext),
searchText: debouncedSearch,
limit: SUGGESTION_LIMIT,
},
{ query: { enabled: isOpen && !disabled, keepPreviousData: true } },
);
const suggestions = useMemo(() => {
const keys = data?.data?.keys ?? {};
return Object.keys(keys)
.filter((key) => key !== value)
.slice(0, SUGGESTION_LIMIT);
}, [data, value]);
return (
<div className={cx('key-search', className)}>
<Input
placeholder={placeholder}
value={value}
disabled={disabled}
autoComplete="off"
onChange={(event): void => {
onChange(event.target.value);
setIsOpen(true);
}}
onFocus={(): void => setIsOpen(true)}
onBlur={(): void => setIsOpen(false)}
testId={testId}
/>
{isOpen && suggestions.length > 0 && (
<div className="key-search__dropdown" data-testid={`${testId}-dropdown`}>
{suggestions.map((name) => (
<button
type="button"
key={name}
className="key-search__option"
// onMouseDown (not onClick) so selection runs before the input blur.
onMouseDown={(event): void => {
event.preventDefault();
onChange(name);
setIsOpen(false);
}}
data-testid={`${testId}-option-${name}`}
>
{name}
</button>
))}
</div>
)}
{isOpen && isFetching && suggestions.length === 0 && (
<div className="key-search__dropdown key-search__dropdown--empty">
Searching
</div>
)}
</div>
);
}
export default KeySearchInput;

View File

@@ -0,0 +1,183 @@
.llm-observability-attribute-mapping {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
&__title {
h1 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
p {
margin: 4px 0 0;
font-size: 13px;
color: var(--bg-vanilla-400);
}
}
&__actions {
display: flex;
align-items: center;
gap: 12px;
}
&__unsaved {
font-size: 13px;
color: var(--bg-amber-400);
}
}
.page-error {
padding: 12px 16px;
border-radius: 4px;
background: var(--bg-cherry-500-opacity-10, rgba(229, 72, 77, 0.1));
color: var(--bg-cherry-400);
font-size: 13px;
}
.page-footer {
font-size: 12px;
color: var(--bg-vanilla-400);
}
.muted {
color: var(--bg-vanilla-400);
}
.am-index-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
background: var(--bg-robin-500);
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 600;
}
.am-row-actions {
display: flex;
align-items: center;
gap: 6px;
}
.am-add-row {
align-self: flex-start;
}
.groups-table__wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.am-table {
&__empty {
padding: 24px 12px;
text-align: center;
color: var(--bg-vanilla-400);
font-size: 13px;
}
}
.groups-table {
&__name {
font-weight: 600;
}
&__name-cell {
display: flex;
align-items: center;
gap: 8px;
}
&__sub-row > td {
padding: 0 16px 12px;
background: var(--bg-ink-400, rgba(255, 255, 255, 0.02));
}
&__filters {
display: flex;
flex-direction: column;
gap: 6px;
}
&__filter {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
&__filter-key {
font-family: 'Geist Mono', monospace;
}
&__edited {
display: flex;
flex-direction: column;
font-size: 12px;
}
}
.mappers-table__wrapper {
display: flex;
flex-direction: column;
gap: 8px;
margin: 4px 0;
}
.mappers-table {
margin: 4px 0;
&__target {
font-family: 'Geist Mono', monospace;
font-weight: 600;
}
&__sources {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
&__source-chip {
display: inline-flex;
align-items: center;
max-width: 220px;
padding: 2px 8px;
border: 1px solid var(--bg-slate-400, rgba(255, 255, 255, 0.12));
border-radius: 6px;
background: var(--bg-ink-300, rgba(255, 255, 255, 0.04));
font-family: 'Geist Mono', monospace;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__source-more {
font-size: 12px;
white-space: nowrap;
}
&__error {
padding: 12px;
font-size: 13px;
color: var(--bg-cherry-400);
}
}
}

View File

@@ -0,0 +1,77 @@
import { useCallback } from 'react';
import AttributeMappingHeader from './AttributeMappingHeader';
import GroupFormDrawer from './GroupFormDrawer';
import MapperGroupsTable from './MapperGroupsTable';
import { useAttributeMappingStore } from './useAttributeMappingStore';
import { useGroupFormDrawer } from './useGroupFormDrawer';
import './LLMObservabilityAttributeMapping.styles.scss';
function LLMObservabilityAttributeMapping(): JSX.Element {
const store = useAttributeMappingStore();
const groupDrawer = useGroupFormDrawer();
const handleGroupSave = useCallback((): void => {
store.upsertGroup(groupDrawer.draft);
groupDrawer.close();
}, [store, groupDrawer]);
const handleGroupDelete = useCallback((): void => {
if (groupDrawer.draft.id) {
store.removeGroup(groupDrawer.draft.id);
}
groupDrawer.close();
}, [store, groupDrawer]);
return (
<div
className="llm-observability-attribute-mapping"
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={store.isDirty}
isSaving={store.isSaving}
onDiscard={store.discard}
onSave={store.save}
/>
{store.saveError && (
<div className="page-error" role="alert">
{store.saveError}
</div>
)}
{store.isError && (
<div className="page-error" role="alert">
Failed to load mapping groups. Please try again.
</div>
)}
<MapperGroupsTable
store={store}
onEditGroup={groupDrawer.openForEdit}
onAddGroup={groupDrawer.openForAdd}
/>
<footer className="page-footer">
Showing {store.groups.length} group{store.groups.length === 1 ? '' : 's'}
</footer>
<GroupFormDrawer
isOpen={groupDrawer.isOpen}
mode={groupDrawer.mode}
draft={groupDrawer.draft}
setDraft={groupDrawer.setDraft}
onClose={groupDrawer.close}
onSave={handleGroupSave}
onDelete={handleGroupDelete}
isSaving={false}
isDeleting={false}
saveError={null}
/>
</div>
);
}
export default LLMObservabilityAttributeMapping;

View File

@@ -0,0 +1,104 @@
.mapper-form {
display: flex;
flex-direction: column;
gap: 20px;
padding: 4px 0;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-400);
}
&__label-hint {
font-weight: 400;
text-transform: none;
letter-spacing: normal;
}
&__hint {
font-size: 12px;
color: var(--bg-vanilla-400);
}
&__sources {
display: flex;
flex-direction: column;
gap: 8px;
}
&__source {
display: flex;
align-items: center;
gap: 6px;
}
&__source-handle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: transparent;
color: var(--bg-vanilla-400);
cursor: grab;
touch-action: none;
user-select: none;
&:active {
cursor: grabbing;
}
}
&__source-index {
width: 20px;
text-align: center;
font-size: 12px;
color: var(--bg-vanilla-400);
}
&__source-input {
flex: 1;
min-width: 0;
font-family: 'Geist Mono', monospace;
}
&__source-select {
flex: 0 0 auto;
width: 120px;
}
&__field-context {
width: 200px;
}
&__error {
padding: 10px 12px;
border-radius: 4px;
background: rgba(229, 72, 77, 0.1);
color: var(--bg-cherry-400);
font-size: 13px;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
}
&__footer-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
}

View File

@@ -0,0 +1,267 @@
import { useEffect, useRef, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { SelectSimple } from '@signozhq/ui/select';
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { Plus, Trash2 } from '@signozhq/icons';
import { v4 as uuid } from 'uuid';
import KeySearchInput from './KeySearchInput';
import SourceAttributeRow from './SourceAttributeRow';
import {
FieldContext,
FieldContextValue,
MapperDraft,
MapperDraftMode,
SourceConfig,
} from './types';
import { createEmptySource, isMapperDraftValid } from './utils';
import './MapperFormDrawer.styles.scss';
const FIELD_CONTEXT_OPTIONS = [
{ value: FieldContext.attribute, label: 'Span attribute' },
{ value: FieldContext.resource, label: 'Resource' },
];
interface MapperFormDrawerProps {
isOpen: boolean;
mode: MapperDraftMode;
draft: MapperDraft;
setDraft: (next: MapperDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
}
function MapperFormDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
}: MapperFormDrawerProps): JSX.Element {
const isEdit = mode === 'edit';
const isValid = isMapperDraftValid(draft);
// Stable per-row ids for the sortable list. These are UI-only (never sent to
// the API and excluded from the draft), so dnd-kit can track rows reliably
// even though sources are stored as a plain array. Re-seeded each time the
// drawer opens; kept in lockstep with the sources array on add/remove/drag.
const [rowIds, setRowIds] = useState<string[]>([]);
const wasOpen = useRef(false);
useEffect(() => {
if (isOpen && !wasOpen.current) {
setRowIds(draft.sources.map(() => uuid()));
}
wasOpen.current = isOpen;
// Only re-seed on the closed→open transition.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const sourceIds = draft.sources.map(
(_, index) => rowIds[index] ?? `pending-${index}`,
);
// 5px activation distance so clicking into the input never starts a drag.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const updateSource = (index: number, patch: Partial<SourceConfig>): void => {
const sources = draft.sources.map((source, i) =>
i === index ? { ...source, ...patch } : source,
);
setDraft({ ...draft, sources });
};
const addSource = (): void => {
setDraft({ ...draft, sources: [...draft.sources, createEmptySource()] });
setRowIds((prev) => [...prev, uuid()]);
};
const removeSource = (index: number): void => {
const sources = draft.sources.filter((_, i) => i !== index);
if (sources.length === 0) {
setDraft({ ...draft, sources: [createEmptySource()] });
setRowIds([uuid()]);
return;
}
setDraft({ ...draft, sources });
setRowIds((prev) => prev.filter((_, i) => i !== index));
};
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const from = sourceIds.indexOf(String(active.id));
const to = sourceIds.indexOf(String(over.id));
if (from === -1 || to === -1) {
return;
}
setDraft({ ...draft, sources: arrayMove(draft.sources, from, to) });
setRowIds((prev) => arrayMove(prev, from, to));
};
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
title={isEdit ? 'Edit mapping' : 'New custom mapping'}
subTitle="Map source attributes onto a canonical target attribute"
width="wide"
testId="mapper-form-drawer"
footer={
<div className="mapper-form__footer">
{isEdit && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
disabled={isDeleting}
testId="mapper-form-delete"
>
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
)}
<div className="mapper-form__footer-actions">
<Button
variant="ghost"
color="secondary"
onClick={onClose}
testId="mapper-form-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
disabled={!isValid || isSaving}
testId="mapper-form-save"
>
{/* eslint-disable-next-line no-nested-ternary */}
{isSaving ? 'Saving…' : isEdit ? 'Save mapping' : 'Create mapping'}
</Button>
</div>
</div>
}
>
<div className="mapper-form">
<div className="mapper-form__field">
<span className="mapper-form__label">Target attribute</span>
<KeySearchInput
placeholder="e.g. gen_ai.content.prompt"
value={draft.name}
fieldContext={draft.fieldContext}
disabled={isEdit}
onChange={(name): void => setDraft({ ...draft, name })}
testId="mapper-form-target"
/>
{isEdit && (
<span className="mapper-form__hint">
The target attribute can&apos;t be changed after creation.
</span>
)}
</div>
<div className="mapper-form__field">
<span className="mapper-form__label">Write target to</span>
<SelectSimple
className="mapper-form__field-context"
items={FIELD_CONTEXT_OPTIONS}
value={draft.fieldContext}
withPortal={false}
onChange={(next): void =>
setDraft({ ...draft, fieldContext: next as FieldContextValue })
}
testId="mapper-form-field-context"
/>
<span className="mapper-form__hint">
Where the standardized attribute is written.
</span>
</div>
<div className="mapper-form__field">
<span className="mapper-form__label">
Source attributes
<span className="mapper-form__label-hint">
{' '}
· priority: top bottom · drag to reorder
</span>
</span>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext items={sourceIds} strategy={verticalListSortingStrategy}>
<div className="mapper-form__sources">
{draft.sources.map((source, index) => (
<SourceAttributeRow
key={sourceIds[index]}
id={sourceIds[index]}
index={index}
value={source}
canRemove={draft.sources.length > 1}
onChange={updateSource}
onRemove={removeSource}
/>
))}
</div>
</SortableContext>
</DndContext>
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
onClick={addSource}
testId="mapper-form-add-source"
>
Add another source
</Button>
</div>
{saveError && (
<div className="mapper-form__error" role="alert">
{saveError}
</div>
)}
</div>
</DrawerWrapper>
);
}
export default MapperFormDrawer;

View File

@@ -0,0 +1,212 @@
import { useState } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@signozhq/ui/table';
import {
ChevronDown,
ChevronRight,
Pencil,
Plus,
Trash2,
} from '@signozhq/icons';
import MappersTable from './MappersTable';
import { DraftGroup } from './types';
import { AttributeMappingStore } from './useAttributeMappingStore';
import { conditionFiltersFromGroup } from './utils';
const COLUMN_COUNT = 4;
interface MapperGroupsTableProps {
store: AttributeMappingStore;
onEditGroup: (group: DraftGroup) => void;
onAddGroup: () => void;
}
function FiltersCell({ group }: { group: DraftGroup }): JSX.Element {
const filters = conditionFiltersFromGroup(group);
if (filters.length === 0) {
return <span className="muted">No condition · always runs</span>;
}
return (
<div
className="groups-table__filters"
data-testid={`group-filters-${group.localId}`}
>
{filters.map((filter) => (
<div
className="groups-table__filter"
key={`${filter.context}:${filter.key}`}
>
<Badge
color={filter.context === 'resource' ? 'amber' : 'robin'}
variant="outline"
>
{filter.context}
</Badge>
<span className="groups-table__filter-key">contains {filter.key}</span>
</div>
))}
</div>
);
}
interface GroupRowProps {
group: DraftGroup;
store: AttributeMappingStore;
isExpanded: boolean;
onToggleExpand: (localId: string) => void;
onEditGroup: (group: DraftGroup) => void;
}
function GroupRow({
group,
store,
isExpanded,
onToggleExpand,
onEditGroup,
}: GroupRowProps): JSX.Element {
return (
<>
<TableRow data-testid={`group-row-${group.localId}`}>
<TableCell>
<div className="groups-table__name-cell">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label={isExpanded ? 'Collapse group' : 'Expand group'}
onClick={(): void => onToggleExpand(group.localId)}
testId={`group-expand-${group.localId}`}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</Button>
<span
className="groups-table__name"
data-testid={`group-name-${group.localId}`}
>
{group.name}
</span>
</div>
</TableCell>
<TableCell>
<FiltersCell group={group} />
</TableCell>
<TableCell>
<span className="muted">{group.mappers.length} mappings</span>
</TableCell>
<TableCell>
<div className="am-row-actions">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Edit group"
onClick={(): void => onEditGroup(group)}
testId={`group-edit-${group.localId}`}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
color="destructive"
size="icon"
aria-label="Delete group"
onClick={(): void => store.removeGroup(group.localId)}
testId={`group-delete-${group.localId}`}
>
<Trash2 size={14} />
</Button>
<Switch
value={group.enabled}
onChange={(checked): void => store.toggleGroup(group.localId, checked)}
testId={`group-enabled-${group.localId}`}
/>
</div>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow className="groups-table__sub-row">
<TableCell colSpan={COLUMN_COUNT}>
<MappersTable group={group} store={store} />
</TableCell>
</TableRow>
)}
</>
);
}
function MapperGroupsTable({
store,
onEditGroup,
onAddGroup,
}: MapperGroupsTableProps): JSX.Element {
const [expandedId, setExpandedId] = useState<string | null>(null);
const toggleExpand = (localId: string): void => {
setExpandedId((current) => (current === localId ? null : localId));
};
return (
<div className="groups-table__wrapper">
<Table testId="mapper-groups-table" className="am-table">
<TableHeader>
<TableRow>
<TableHead>Group name</TableHead>
<TableHead>Filters</TableHead>
<TableHead>Mappings</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{store.isLoading && store.groups.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="am-table__empty">
Loading groups
</TableCell>
</TableRow>
)}
{!store.isLoading && store.groups.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="am-table__empty">
No mapping groups yet.
</TableCell>
</TableRow>
)}
{store.groups.map((group) => (
<GroupRow
key={group.localId}
group={group}
store={store}
isExpanded={expandedId === group.localId}
onToggleExpand={toggleExpand}
onEditGroup={onEditGroup}
/>
))}
</TableBody>
</Table>
<Button
variant="ghost"
color="primary"
prefix={<Plus size={14} />}
className="am-add-row"
onClick={onAddGroup}
testId="add-group-row"
>
Add a new group
</Button>
</div>
);
}
export default MapperGroupsTable;

View File

@@ -0,0 +1,208 @@
import { useCallback } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@signozhq/ui/table';
import { Pencil, Plus, Trash2 } from '@signozhq/icons';
import IndexBadge from './IndexBadge';
import MapperFormDrawer from './MapperFormDrawer';
import { DraftGroup, DraftMapper, FieldContext } from './types';
import { AttributeMappingStore } from './useAttributeMappingStore';
import { useMapperFormDrawer } from './useMapperFormDrawer';
const MAX_VISIBLE_SOURCES = 3;
const COLUMN_COUNT = 5;
interface MappersTableProps {
group: DraftGroup;
store: AttributeMappingStore;
}
function SourcesCell({ mapper }: { mapper: DraftMapper }): JSX.Element {
if (mapper.sources.length === 0) {
return <span className="muted"></span>;
}
const visible = mapper.sources.slice(0, MAX_VISIBLE_SOURCES);
const remaining = mapper.sources.length - visible.length;
return (
<div
className="mappers-table__sources"
data-testid={`mapper-sources-${mapper.localId}`}
>
{visible.map((source) => (
<span
className="mappers-table__source-chip"
key={`${source.context}:${source.key}`}
title={source.key}
>
{source.key}
</span>
))}
{remaining > 0 && (
<span className="mappers-table__source-more muted">+{remaining} more</span>
)}
</div>
);
}
interface MapperRowProps {
mapper: DraftMapper;
index: number;
onEdit: (mapper: DraftMapper) => void;
onDelete: (mapperLocalId: string) => void;
onToggle: (mapperLocalId: string, enabled: boolean) => void;
}
function MapperRow({
mapper,
index,
onEdit,
onDelete,
onToggle,
}: MapperRowProps): JSX.Element {
return (
<TableRow data-testid={`mapper-row-${mapper.localId}`}>
<TableCell>
<IndexBadge index={index} />
</TableCell>
<TableCell>
<span
className="mappers-table__target"
data-testid={`mapper-target-${mapper.localId}`}
>
{mapper.name}
</span>
</TableCell>
<TableCell>
<SourcesCell mapper={mapper} />
</TableCell>
<TableCell>
<Badge
color={mapper.fieldContext === FieldContext.resource ? 'amber' : 'robin'}
variant="outline"
>
{mapper.fieldContext}
</Badge>
</TableCell>
<TableCell>
<div className="am-row-actions">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Edit mapping"
onClick={(): void => onEdit(mapper)}
testId={`mapper-edit-${mapper.localId}`}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
color="destructive"
size="icon"
aria-label="Delete mapping"
onClick={(): void => onDelete(mapper.localId)}
testId={`mapper-delete-${mapper.localId}`}
>
<Trash2 size={14} />
</Button>
<Switch
value={mapper.enabled}
onChange={(checked): void => onToggle(mapper.localId, checked)}
testId={`mapper-enabled-${mapper.localId}`}
/>
</div>
</TableCell>
</TableRow>
);
}
function MappersTable({ group, store }: MappersTableProps): JSX.Element {
const drawer = useMapperFormDrawer();
const handleSave = useCallback((): void => {
store.upsertMapper(group.localId, drawer.draft);
drawer.close();
}, [store, group.localId, drawer]);
const handleDelete = useCallback((): void => {
if (drawer.draft.id) {
store.removeMapper(group.localId, drawer.draft.id);
}
drawer.close();
}, [store, group.localId, drawer]);
return (
<div className="mappers-table__wrapper">
<Table testId={`mappers-table-${group.localId}`} className="am-table">
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Target</TableHead>
<TableHead>Sources</TableHead>
<TableHead>Writes to</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.mappers.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="am-table__empty">
No mappings in this group yet.
</TableCell>
</TableRow>
)}
{group.mappers.map((mapper, index) => (
<MapperRow
key={mapper.localId}
mapper={mapper}
index={index}
onEdit={drawer.openForEdit}
onDelete={(localId): void => store.removeMapper(group.localId, localId)}
onToggle={(localId, enabled): void =>
store.toggleMapper(group.localId, localId, enabled)
}
/>
))}
</TableBody>
</Table>
<Button
variant="ghost"
color="primary"
size="sm"
prefix={<Plus size={14} />}
className="am-add-row"
onClick={drawer.openForAdd}
testId={`add-mapper-${group.localId}`}
>
Add mapping
</Button>
<MapperFormDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
draft={drawer.draft}
setDraft={drawer.setDraft}
onClose={drawer.close}
onSave={handleSave}
onDelete={handleDelete}
isSaving={false}
isDeleting={false}
saveError={null}
/>
</div>
);
}
export default MappersTable;

View File

@@ -0,0 +1,115 @@
import { Button } from '@signozhq/ui/button';
import { SelectSimple } from '@signozhq/ui/select';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, X } from '@signozhq/icons';
import KeySearchInput from './KeySearchInput';
import {
FieldContext,
FieldContextValue,
MapperOperation,
MapperOperationValue,
SourceConfig,
} from './types';
const CONTEXT_OPTIONS = [
{ value: FieldContext.attribute, label: 'Attribute' },
{ value: FieldContext.resource, label: 'Resource' },
];
const OPERATION_OPTIONS = [
{ value: MapperOperation.move, label: 'Move' },
{ value: MapperOperation.copy, label: 'Copy' },
];
interface SourceAttributeRowProps {
id: string;
index: number;
value: SourceConfig;
canRemove: boolean;
onChange: (index: number, patch: Partial<SourceConfig>) => void;
onRemove: (index: number) => void;
}
// A single draggable source row. Order = priority (top wins). Each source can
// be read from a span attribute or the resource, and moved (delete source) or
// copied (keep source). Only the grip is a drag handle.
function SourceAttributeRow({
id,
index,
value,
canRemove,
onChange,
onRemove,
}: SourceAttributeRowProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
return (
<div className="mapper-form__source" ref={setNodeRef} style={style}>
<div
className="mapper-form__source-handle"
data-testid={`mapper-form-source-handle-${index}`}
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</div>
<span className="mapper-form__source-index">{index + 1}</span>
<KeySearchInput
className="mapper-form__source-input"
placeholder="Source attribute key"
value={value.key}
fieldContext={value.context}
onChange={(key): void => onChange(index, { key })}
testId={`mapper-form-source-${index}`}
/>
<SelectSimple
className="mapper-form__source-select"
items={CONTEXT_OPTIONS}
value={value.context}
withPortal={false}
onChange={(next): void =>
onChange(index, { context: next as FieldContextValue })
}
testId={`mapper-form-source-context-${index}`}
/>
<SelectSimple
className="mapper-form__source-select"
items={OPERATION_OPTIONS}
value={value.operation}
withPortal={false}
onChange={(next): void =>
onChange(index, { operation: next as MapperOperationValue })
}
testId={`mapper-form-source-operation-${index}`}
/>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Remove source"
disabled={!canRemove}
onClick={(): void => onRemove(index)}
testId={`mapper-form-source-remove-${index}`}
>
<X size={14} />
</Button>
</div>
);
}
export default SourceAttributeRow;

View File

@@ -0,0 +1,64 @@
import { useState } from 'react';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen } from 'tests/test-utils';
import ConditionKeyList from '../ConditionKeyList';
import { FieldContext } from '../types';
const FIELDS_ENDPOINT = '*/api/v1/fields/keys';
function Harness({ initial = [] as string[] }): JSX.Element {
const [keys, setKeys] = useState<string[]>(initial);
return (
<ConditionKeyList
label="Attributes"
keys={keys}
placeholder="key"
addLabel="Add attribute key"
testIdPrefix="cond"
fieldContext={FieldContext.attribute}
onChange={setKeys}
/>
);
}
describe('ConditionKeyList', () => {
beforeEach(() => {
server.use(
rest.get(FIELDS_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { complete: true, keys: {} } }),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders no key rows and only the add button when empty', () => {
render(<Harness />);
expect(screen.queryByTestId('cond-0')).not.toBeInTheDocument();
expect(screen.getByTestId('cond-add')).toBeInTheDocument();
});
it('adds a key row when the add button is clicked', () => {
render(<Harness />);
fireEvent.click(screen.getByTestId('cond-add'));
expect(screen.getByTestId('cond-0')).toBeInTheDocument();
});
it('removes a key row when its remove button is clicked', () => {
render(<Harness initial={['gen_ai.', 'llm.']} />);
expect(screen.getByTestId('cond-0')).toBeInTheDocument();
expect(screen.getByTestId('cond-1')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('cond-remove-1'));
expect(screen.queryByTestId('cond-1')).not.toBeInTheDocument();
expect(screen.getByTestId('cond-0')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,123 @@
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import KeySearchInput from '../KeySearchInput';
import { FieldContext } from '../types';
const FIELDS_ENDPOINT = '*/api/v1/fields/keys';
const mockKeysResponse = {
status: 'success',
data: {
complete: true,
keys: {
'gen_ai.request.model': [
{ name: 'gen_ai.request.model', fieldContext: 'attribute' },
],
'llm.model': [{ name: 'llm.model', fieldContext: 'attribute' }],
},
},
};
describe('KeySearchInput', () => {
let lastRequestParams: Record<string, string | null> = {};
beforeEach(() => {
lastRequestParams = {};
server.use(
rest.get(FIELDS_ENDPOINT, (req, res, ctx) => {
lastRequestParams = {
signal: req.url.searchParams.get('signal'),
fieldContext: req.url.searchParams.get('fieldContext'),
searchText: req.url.searchParams.get('searchText'),
};
return res(ctx.status(200), ctx.json(mockKeysResponse));
}),
);
});
afterEach(() => {
server.resetHandlers();
});
it('does not show suggestions until the input is focused', () => {
render(
<KeySearchInput
value=""
fieldContext={FieldContext.attribute}
onChange={jest.fn()}
testId="key"
/>,
);
expect(screen.queryByTestId('key-dropdown')).not.toBeInTheDocument();
});
it('shows suggestions on focus and selecting one calls onChange with the key', async () => {
const onChange = jest.fn();
render(
<KeySearchInput
value=""
fieldContext={FieldContext.attribute}
onChange={onChange}
testId="key"
/>,
);
fireEvent.focus(screen.getByTestId('key'));
const option = await screen.findByTestId('key-option-gen_ai.request.model');
fireEvent.mouseDown(option);
expect(onChange).toHaveBeenCalledWith('gen_ai.request.model');
});
it('queries traces with the resource context when fieldContext is resource', async () => {
render(
<KeySearchInput
value=""
fieldContext={FieldContext.resource}
onChange={jest.fn()}
testId="key"
/>,
);
fireEvent.focus(screen.getByTestId('key'));
await waitFor(() => expect(lastRequestParams.fieldContext).toBe('resource'));
expect(lastRequestParams.signal).toBe('traces');
});
it('accepts free text typed by the user (custom key)', () => {
const onChange = jest.fn();
render(
<KeySearchInput
value=""
fieldContext={FieldContext.attribute}
onChange={onChange}
testId="key"
/>,
);
fireEvent.change(screen.getByTestId('key'), {
target: { value: 'my.custom.key' },
});
expect(onChange).toHaveBeenCalledWith('my.custom.key');
});
it('does not query while disabled', () => {
render(
<KeySearchInput
value=""
fieldContext={FieldContext.attribute}
disabled
onChange={jest.fn()}
testId="key"
/>,
);
fireEvent.focus(screen.getByTestId('key'));
expect(screen.queryByTestId('key-dropdown')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,72 @@
import { SpantypesSpanMapperGroupDTO } from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen } from 'tests/test-utils';
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
const GROUPS_ENDPOINT = '*/api/v1/span_mapper_groups';
const MAPPERS_ENDPOINT = '*/api/v1/span_mapper_groups/:groupId/span_mappers';
const mockGroups: SpantypesSpanMapperGroupDTO[] = [
{
id: 'group-openai',
orgId: 'org-1',
name: 'OpenAI gateway',
enabled: true,
condition: { attributes: ['gen_ai.'], resource: [] },
updatedBy: 'gaurav.tewari@signoz.io',
updatedAt: '2026-06-10T09:30:00Z',
},
];
describe('LLMObservabilityAttributeMapping', () => {
beforeEach(() => {
server.use(
rest.get(MAPPERS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: { items: [] } })),
),
rest.get(GROUPS_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { items: mockGroups } }),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders the page header and the group row', async () => {
render(<LLMObservabilityAttributeMapping />);
await screen.findByTestId('group-name-group-openai');
expect(screen.getByText('Attribute Mapping')).toBeInTheDocument();
expect(screen.getByText('OpenAI gateway')).toBeInTheDocument();
});
it('opens the group drawer in Add mode with empty name', async () => {
render(<LLMObservabilityAttributeMapping />);
await screen.findByTestId('group-name-group-openai');
fireEvent.click(screen.getByTestId('add-group-row'));
const input = (await screen.findByTestId(
'group-form-name',
)) as HTMLInputElement;
expect(input.value).toBe('');
});
it('opens the group drawer in Edit mode prefilled with the group name', async () => {
render(<LLMObservabilityAttributeMapping />);
await screen.findByTestId('group-name-group-openai');
fireEvent.click(screen.getByTestId('group-edit-group-openai'));
const input = (await screen.findByTestId(
'group-form-name',
)) as HTMLInputElement;
expect(input.value).toBe('OpenAI gateway');
});
});

View File

@@ -0,0 +1,115 @@
import { useState } from 'react';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen } from 'tests/test-utils';
import MapperFormDrawer from '../MapperFormDrawer';
import {
FieldContext,
MapperDraft,
MapperDraftMode,
MapperOperation,
} from '../types';
import { EMPTY_MAPPER_DRAFT } from '../utils';
const FIELDS_ENDPOINT = '*/api/v1/fields/keys';
const filledDraft: MapperDraft = {
id: null,
name: 'gen_ai.request.model',
fieldContext: FieldContext.attribute,
sources: [
{
key: 'llm.model',
context: FieldContext.attribute,
operation: MapperOperation.move,
},
],
enabled: true,
};
interface HarnessProps {
initialDraft?: MapperDraft;
mode?: MapperDraftMode;
onSave?: () => void;
onDelete?: () => void;
}
function Harness({
initialDraft = EMPTY_MAPPER_DRAFT,
mode = 'add',
onSave = jest.fn(),
onDelete = jest.fn(),
}: HarnessProps): JSX.Element {
const [draft, setDraft] = useState<MapperDraft>(initialDraft);
return (
<MapperFormDrawer
isOpen
mode={mode}
draft={draft}
setDraft={setDraft}
onClose={jest.fn()}
onSave={onSave}
onDelete={onDelete}
isSaving={false}
isDeleting={false}
saveError={null}
/>
);
}
describe('MapperFormDrawer', () => {
beforeEach(() => {
server.use(
rest.get(FIELDS_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { complete: true, keys: {} } }),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('disables Create when the target/sources are empty', () => {
render(<Harness />);
expect(screen.getByTestId('mapper-form-save')).toBeDisabled();
});
it('enables Create with a target and a source key, and calls onSave', () => {
const onSave = jest.fn();
render(<Harness initialDraft={filledDraft} onSave={onSave} />);
const save = screen.getByTestId('mapper-form-save');
expect(save).not.toBeDisabled();
fireEvent.click(save);
expect(onSave).toHaveBeenCalledTimes(1);
});
it('adds a source row when "Add another source" is clicked', () => {
render(<Harness initialDraft={filledDraft} />);
expect(screen.queryByTestId('mapper-form-source-1')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('mapper-form-add-source'));
expect(screen.getByTestId('mapper-form-source-1')).toBeInTheDocument();
});
it('makes the target read-only in edit mode and shows delete', () => {
const onDelete = jest.fn();
render(
<Harness
initialDraft={{ ...filledDraft, id: 'local-mapper-1' }}
mode="edit"
onDelete={onDelete}
/>,
);
expect(screen.getByTestId('mapper-form-target')).toBeDisabled();
fireEvent.click(screen.getByTestId('mapper-form-delete'));
expect(onDelete).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,146 @@
import { persistDraft, SaveMutations } from '../saveDraft';
import {
DraftGroup,
DraftMapper,
FieldContext,
MapperOperation,
} from '../types';
function mapper(over: Partial<DraftMapper>): DraftMapper {
return {
localId: 'm',
serverId: 'm',
name: 'm',
fieldContext: FieldContext.attribute,
sources: [
{
key: 'x',
context: FieldContext.attribute,
operation: MapperOperation.move,
},
],
enabled: true,
...over,
};
}
function group(over: Partial<DraftGroup>): DraftGroup {
return {
localId: 'g',
serverId: 'g',
name: 'g',
attributes: [],
resource: [],
enabled: true,
mappers: [],
...over,
};
}
interface RecordedCalls {
createGroup: unknown[];
updateGroup: [string, unknown][];
deleteGroup: string[];
createMapper: [string, unknown][];
updateMapper: [string, string, unknown][];
deleteMapper: [string, string][];
}
function makeMutations(): { calls: RecordedCalls; mutations: SaveMutations } {
const calls: RecordedCalls = {
createGroup: [],
updateGroup: [],
deleteGroup: [],
createMapper: [],
updateMapper: [],
deleteMapper: [],
};
const mutations: SaveMutations = {
createGroup: async (data): Promise<string> => {
calls.createGroup.push(data);
return 'new-group-id';
},
updateGroup: async (id, data): Promise<void> => {
calls.updateGroup.push([id, data]);
},
deleteGroup: async (id): Promise<void> => {
calls.deleteGroup.push(id);
},
createMapper: async (gid, data): Promise<void> => {
calls.createMapper.push([gid, data]);
},
updateMapper: async (gid, mid, data): Promise<void> => {
calls.updateMapper.push([gid, mid, data]);
},
deleteMapper: async (gid, mid): Promise<void> => {
calls.deleteMapper.push([gid, mid]);
},
};
return { calls, mutations };
}
describe('persistDraft', () => {
it('issues the minimal set of create/update/delete calls', async () => {
const snapshot: DraftGroup[] = [
group({
localId: 'g1',
serverId: 'g1',
name: 'G1',
mappers: [
mapper({ localId: 'm1', serverId: 'm1', name: 'keep' }),
mapper({ localId: 'mdel', serverId: 'mdel', name: 'del' }),
],
}),
group({ localId: 'g2', serverId: 'g2', name: 'G2' }),
];
const draft: DraftGroup[] = [
group({
localId: 'g1',
serverId: 'g1',
name: 'G1-renamed', // changed -> update
mappers: [
mapper({ localId: 'm1', serverId: 'm1', name: 'keep' }), // unchanged
mapper({ localId: 'local-mapper-x', serverId: null, name: 'fresh' }), // new
],
}),
// g2 removed -> delete
group({
localId: 'local-group-y',
serverId: null,
name: 'G3',
mappers: [
mapper({ localId: 'local-mapper-z', serverId: null, name: 'g3map' }),
],
}),
];
const { calls, mutations } = makeMutations();
await persistDraft(snapshot, draft, mutations);
expect(calls.deleteGroup).toStrictEqual(['g2']);
expect(calls.updateGroup.map(([id]) => id)).toStrictEqual(['g1']);
expect(calls.deleteMapper).toStrictEqual([['g1', 'mdel']]);
// no-op mapper is not updated
expect(calls.updateMapper).toStrictEqual([]);
// new group created once, then its mapper created under the returned id
expect(calls.createGroup).toHaveLength(1);
const createMapperGroupIds = calls.createMapper.map(([gid]) => gid).sort();
expect(createMapperGroupIds).toStrictEqual(['g1', 'new-group-id']);
});
it('does nothing when the draft equals the snapshot', async () => {
const snapshot: DraftGroup[] = [group({ localId: 'g1', serverId: 'g1' })];
const draft: DraftGroup[] = [group({ localId: 'g1', serverId: 'g1' })];
const { calls, mutations } = makeMutations();
await persistDraft(snapshot, draft, mutations);
expect(calls.createGroup).toHaveLength(0);
expect(calls.updateGroup).toHaveLength(0);
expect(calls.deleteGroup).toHaveLength(0);
expect(calls.createMapper).toHaveLength(0);
expect(calls.updateMapper).toHaveLength(0);
expect(calls.deleteMapper).toHaveLength(0);
});
});

View File

@@ -0,0 +1,330 @@
import {
DraftGroup,
FieldContext,
Mapper,
MapperDraft,
MapperGroup,
MapperOperation,
SourceConfig,
} from '../types';
import {
buildDraftGroup,
buildDraftMapper,
buildPostableGroup,
buildPostableMapper,
buildUpdatableMapper,
cleanKeys,
conditionFiltersFromGroup,
EMPTY_GROUP_DRAFT,
EMPTY_MAPPER_DRAFT,
formatTimestamp,
getMapperSources,
groupDraftFromNode,
isGroupDraftValid,
isMapperDraftValid,
mapperDraftFromNode,
nodeFromGroupDraft,
nodeFromMapperDraft,
} from '../utils';
function src(
key: string,
operation = MapperOperation.copy,
context = FieldContext.attribute,
): SourceConfig {
return { key, context, operation };
}
function mapperDraft(over: Partial<MapperDraft>): MapperDraft {
return {
id: null,
name: 'target',
fieldContext: FieldContext.attribute,
sources: [src('a')],
enabled: true,
...over,
};
}
const mapper: Mapper = {
id: 'm1',
group_id: 'g1',
name: 'gen_ai.request.model',
enabled: true,
fieldContext: FieldContext.attribute,
config: {
sources: [
{
key: 'low',
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority: 1,
},
{
key: 'high',
context: FieldContext.resource,
operation: MapperOperation.move,
priority: 3,
},
{
key: 'mid',
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority: 2,
},
],
},
};
const group: MapperGroup = {
id: 'g1',
orgId: 'org1',
name: 'OpenAI',
enabled: true,
condition: { attributes: ['gen_ai.', 'llm.'], resource: [] },
};
describe('attribute-mapping utils', () => {
describe('cleanKeys', () => {
it('trims, drops empties, and de-duplicates preserving order', () => {
expect(cleanKeys([' a ', '', 'b', 'a', ' '])).toStrictEqual(['a', 'b']);
});
});
describe('getMapperSources', () => {
it('returns sources sorted by priority descending, preserving context/operation', () => {
expect(getMapperSources(mapper)).toStrictEqual([
{
key: 'high',
context: FieldContext.resource,
operation: MapperOperation.move,
},
{
key: 'mid',
context: FieldContext.attribute,
operation: MapperOperation.copy,
},
{
key: 'low',
context: FieldContext.attribute,
operation: MapperOperation.copy,
},
]);
});
it('returns empty array when there are no sources', () => {
expect(
getMapperSources({ ...mapper, config: { sources: null } }),
).toStrictEqual([]);
});
});
describe('conditionFiltersFromGroup', () => {
it('lists attribute clauses first, then resource clauses', () => {
expect(
conditionFiltersFromGroup({
attributes: ['gen_ai.', 'llm.'],
resource: ['service.name'],
}),
).toStrictEqual([
{ context: 'attribute', key: 'gen_ai.' },
{ context: 'attribute', key: 'llm.' },
{ context: 'resource', key: 'service.name' },
]);
});
});
describe('formatTimestamp', () => {
it('renders a dash for missing timestamps', () => {
expect(formatTimestamp(undefined)).toBe('—');
});
});
describe('draft-tree builders', () => {
it('builds a draft mapper node carrying server id, fieldContext and sources', () => {
expect(buildDraftMapper(mapper)).toStrictEqual({
localId: 'm1',
serverId: 'm1',
name: 'gen_ai.request.model',
fieldContext: FieldContext.attribute,
sources: [
{
key: 'high',
context: FieldContext.resource,
operation: MapperOperation.move,
},
{
key: 'mid',
context: FieldContext.attribute,
operation: MapperOperation.copy,
},
{
key: 'low',
context: FieldContext.attribute,
operation: MapperOperation.copy,
},
],
enabled: true,
});
});
it('builds a draft group node with nested mappers', () => {
const node = buildDraftGroup(group, [mapper]);
expect(node.localId).toBe('g1');
expect(node.attributes).toStrictEqual(['gen_ai.', 'llm.']);
expect(node.mappers).toHaveLength(1);
expect(node.mappers[0].localId).toBe('m1');
});
});
describe('node <-> form conversions', () => {
const node: DraftGroup = {
localId: 'g1',
serverId: 'g1',
name: 'OpenAI',
attributes: ['gen_ai.'],
resource: ['service.name'],
enabled: true,
mappers: [],
};
it('builds a form draft from a group node', () => {
expect(groupDraftFromNode(node)).toStrictEqual({
id: 'g1',
name: 'OpenAI',
attributes: ['gen_ai.'],
resource: ['service.name'],
enabled: true,
});
});
it('updates an existing group node, preserving its ids and mappers', () => {
const existing = { ...node, mappers: [buildDraftMapper(mapper)] };
const updated = nodeFromGroupDraft(
{
id: 'g1',
name: ' Renamed ',
attributes: ['a', '', 'a'],
resource: ['service.name', ''],
enabled: false,
},
existing,
);
expect(updated.serverId).toBe('g1');
expect(updated.name).toBe('Renamed');
expect(updated.attributes).toStrictEqual(['a']);
expect(updated.resource).toStrictEqual(['service.name']);
expect(updated.mappers).toHaveLength(1);
});
it('round-trips a mapper node through the form and de-dups sources by key', () => {
const draft = mapperDraftFromNode(buildDraftMapper(mapper));
expect(draft.id).toBe('m1');
expect(draft.fieldContext).toBe(FieldContext.attribute);
const created = nodeFromMapperDraft(
mapperDraft({
id: null,
sources: [src('a', MapperOperation.move), src(' '), src('a'), src('b')],
}),
);
expect(created.serverId).toBeNull();
expect(created.localId).toMatch(/^local-mapper-/);
expect(created.sources).toStrictEqual([
{
key: 'a',
context: FieldContext.attribute,
operation: MapperOperation.move,
},
{
key: 'b',
context: FieldContext.attribute,
operation: MapperOperation.copy,
},
]);
});
});
describe('validation', () => {
it('validates a mapper draft (name + at least one keyed source)', () => {
expect(isMapperDraftValid(EMPTY_MAPPER_DRAFT)).toBe(false);
expect(
isMapperDraftValid(mapperDraft({ name: 'x', sources: [src(' ')] })),
).toBe(false);
expect(
isMapperDraftValid(mapperDraft({ name: 'x', sources: [src('a')] })),
).toBe(true);
});
it('validates a group draft (name only)', () => {
expect(isGroupDraftValid(EMPTY_GROUP_DRAFT)).toBe(false);
expect(
isGroupDraftValid({
id: null,
name: 'g',
attributes: [],
resource: [],
enabled: true,
}),
).toBe(true);
});
});
describe('API payload builders', () => {
it('derives descending priorities and carries per-source context/operation', () => {
const payload = buildPostableMapper(
mapperDraft({
name: ' target ',
fieldContext: FieldContext.resource,
sources: [
src('a', MapperOperation.move),
src('b', MapperOperation.copy, FieldContext.resource),
],
}),
);
expect(payload.name).toBe('target');
expect(payload.fieldContext).toBe(FieldContext.resource);
expect(payload.config.sources).toStrictEqual([
{
key: 'a',
context: FieldContext.attribute,
operation: MapperOperation.move,
priority: 2,
},
{
key: 'b',
context: FieldContext.resource,
operation: MapperOperation.copy,
priority: 1,
},
]);
});
it('omits name on the mapper update payload (immutable target)', () => {
const payload = buildUpdatableMapper(
mapperDraft({
id: 'm1',
enabled: false,
fieldContext: FieldContext.resource,
}),
);
expect(payload).not.toHaveProperty('name');
expect(payload.enabled).toBe(false);
expect(payload.fieldContext).toBe(FieldContext.resource);
});
it('cleans both attribute and resource condition keys', () => {
const payload = buildPostableGroup({
id: null,
name: 'g',
attributes: ['gen_ai.', '', 'gen_ai.'],
resource: ['service.name', ' ', 'service.name'],
enabled: true,
});
expect(payload.condition).toStrictEqual({
attributes: ['gen_ai.'],
resource: ['service.name'],
});
});
});
});

View File

@@ -0,0 +1,185 @@
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DraftGroup, DraftMapper, SourceConfig } from './types';
import {
buildPostableGroup,
buildPostableMapper,
buildUpdatableGroup,
buildUpdatableMapper,
} from './utils';
// Thin persistence surface the store wires to the generated mutations.
// createGroup returns the new server id so its mappers can be created under it.
export interface SaveMutations {
createGroup: (data: SpantypesPostableSpanMapperGroupDTO) => Promise<string>;
updateGroup: (
groupId: string,
data: SpantypesUpdatableSpanMapperGroupDTO,
) => Promise<void>;
deleteGroup: (groupId: string) => Promise<void>;
createMapper: (
groupId: string,
data: SpantypesPostableSpanMapperDTO,
) => Promise<void>;
updateMapper: (
groupId: string,
mapperId: string,
data: SpantypesUpdatableSpanMapperDTO,
) => Promise<void>;
deleteMapper: (groupId: string, mapperId: string) => Promise<void>;
}
function arraysEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
return (
a.length === b.length &&
a.every(
(source, index) =>
source.key === b[index].key &&
source.context === b[index].context &&
source.operation === b[index].operation,
)
);
}
function groupChanged(snapshot: DraftGroup, draft: DraftGroup): boolean {
return (
snapshot.name !== draft.name ||
snapshot.enabled !== draft.enabled ||
!arraysEqual(snapshot.attributes, draft.attributes) ||
!arraysEqual(snapshot.resource, draft.resource)
);
}
function mapperChanged(snapshot: DraftMapper, draft: DraftMapper): boolean {
return (
snapshot.enabled !== draft.enabled ||
snapshot.fieldContext !== draft.fieldContext ||
!sourcesEqual(snapshot.sources, draft.sources)
);
}
function groupDraftOf(
node: DraftGroup,
): Parameters<typeof buildPostableGroup>[0] {
return {
id: node.serverId,
name: node.name,
attributes: node.attributes,
resource: node.resource,
enabled: node.enabled,
};
}
function mapperDraftOf(
node: DraftMapper,
): Parameters<typeof buildPostableMapper>[0] {
return {
id: node.serverId,
name: node.name,
fieldContext: node.fieldContext,
sources: node.sources,
enabled: node.enabled,
};
}
async function persistMappers(
groupServerId: string,
snapshotMappers: DraftMapper[],
draftMappers: DraftMapper[],
m: SaveMutations,
): Promise<void> {
const snapById = new Map(
snapshotMappers
.filter((mapper) => mapper.serverId)
.map((mapper) => [mapper.serverId as string, mapper]),
);
const draftServerIds = new Set(
draftMappers
.filter((mapper) => mapper.serverId)
.map((mapper) => mapper.serverId as string),
);
// Deleted mappers.
await Promise.all(
snapshotMappers
.filter((mapper) => mapper.serverId && !draftServerIds.has(mapper.serverId))
.map((mapper) => m.deleteMapper(groupServerId, mapper.serverId as string)),
);
// Created + updated mappers (sequential to keep ordering deterministic).
for (const mapper of draftMappers) {
if (!mapper.serverId) {
// eslint-disable-next-line no-await-in-loop
await m.createMapper(
groupServerId,
buildPostableMapper(mapperDraftOf(mapper)),
);
} else {
const snap = snapById.get(mapper.serverId);
if (!snap || mapperChanged(snap, mapper)) {
// eslint-disable-next-line no-await-in-loop
await m.updateMapper(
groupServerId,
mapper.serverId,
buildUpdatableMapper(mapperDraftOf(mapper)),
);
}
}
}
}
// Diffs the staged tree against the server snapshot and issues the minimal set
// of create/update/delete calls to reconcile them.
export async function persistDraft(
snapshot: DraftGroup[],
draft: DraftGroup[],
m: SaveMutations,
): Promise<void> {
const snapById = new Map(
snapshot
.filter((group) => group.serverId)
.map((group) => [group.serverId as string, group]),
);
const draftServerIds = new Set(
draft
.filter((group) => group.serverId)
.map((group) => group.serverId as string),
);
// Deleted groups (cascades mappers server-side).
await Promise.all(
snapshot
.filter((group) => group.serverId && !draftServerIds.has(group.serverId))
.map((group) => m.deleteGroup(group.serverId as string)),
);
for (const group of draft) {
if (!group.serverId) {
// eslint-disable-next-line no-await-in-loop
const newId = await m.createGroup(buildPostableGroup(groupDraftOf(group)));
// eslint-disable-next-line no-await-in-loop
await persistMappers(newId, [], group.mappers, m);
continue;
}
const snap = snapById.get(group.serverId);
if (!snap || groupChanged(snap, group)) {
// eslint-disable-next-line no-await-in-loop
await m.updateGroup(
group.serverId,
buildUpdatableGroup(groupDraftOf(group)),
);
}
// eslint-disable-next-line no-await-in-loop
await persistMappers(group.serverId, snap?.mappers ?? [], group.mappers, m);
}
}

View File

@@ -0,0 +1,84 @@
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperConfigDTO,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupConditionDTO,
SpantypesSpanMapperGroupDTO,
SpantypesSpanMapperOperationDTO,
SpantypesSpanMapperSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
export type MapperGroup = SpantypesSpanMapperGroupDTO;
export type MapperGroupCondition =
NonNullable<SpantypesSpanMapperGroupConditionDTO>;
export type Mapper = SpantypesSpanMapperDTO;
export type MapperConfig = SpantypesSpanMapperConfigDTO;
export type MapperSource = SpantypesSpanMapperSourceDTO;
export const FieldContext = SpantypesFieldContextDTO;
export const MapperOperation = SpantypesSpanMapperOperationDTO;
export type FieldContextValue = SpantypesFieldContextDTO;
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
// A single human-readable condition clause shown in the group's Filters column.
export interface ConditionFilter {
context: 'attribute' | 'resource';
key: string;
}
export type MapperDraftMode = 'add' | 'edit';
// One source candidate. `context` is where the key is read from (span
// attribute or resource); `operation` is move (delete source) or copy (keep).
// Priority is implicit in list order (top wins), derived on save.
export interface SourceConfig {
key: string;
context: SpantypesFieldContextDTO;
operation: SpantypesSpanMapperOperationDTO;
}
// Editable form state for a mapper. `sources` is ordered highest priority
// first; `fieldContext` is where the standardized target is written.
export interface MapperDraft {
id: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
// Editable form state for a group. The group runs when a span carries a
// span-attribute key matching `attributes` OR a resource key matching
// `resource` (plain substring match).
export interface GroupDraft {
id: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
}
// Working-copy node for a mapper. `localId` is a stable client key (the server
// id once persisted, or a temporary id for not-yet-saved rows). `serverId` is
// null until the row has been persisted.
export interface DraftMapper {
localId: string;
serverId: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
// Working-copy node for a group, holding its mappers inline so the whole tree
// can be staged locally and diffed against the server snapshot on save.
export interface DraftGroup {
localId: string;
serverId: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
mappers: DraftMapper[];
}

View File

@@ -0,0 +1,287 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueries, useQueryClient } from 'react-query';
import {
getListSpanMappersQueryOptions,
useCreateSpanMapper,
useCreateSpanMapperGroup,
useDeleteSpanMapper,
useDeleteSpanMapperGroup,
useListSpanMapperGroups,
useUpdateSpanMapper,
useUpdateSpanMapperGroup,
} from 'api/generated/services/spanmapper';
import { persistDraft, SaveMutations } from './saveDraft';
import {
DraftGroup,
GroupDraft,
Mapper,
MapperDraft,
MapperGroup,
} from './types';
import {
buildDraftGroup,
nodeFromGroupDraft,
nodeFromMapperDraft,
} from './utils';
const GROUPS_KEY_PREFIX = '/api/v1/span_mapper_groups';
function clone(groups: DraftGroup[]): DraftGroup[] {
return JSON.parse(JSON.stringify(groups)) as DraftGroup[];
}
export interface AttributeMappingStore {
groups: DraftGroup[];
isLoading: boolean;
isError: boolean;
isDirty: boolean;
isSaving: boolean;
saveError: string | null;
upsertGroup: (draft: GroupDraft) => void;
removeGroup: (localId: string) => void;
toggleGroup: (localId: string, enabled: boolean) => void;
upsertMapper: (groupLocalId: string, draft: MapperDraft) => void;
removeMapper: (groupLocalId: string, mapperLocalId: string) => void;
toggleMapper: (
groupLocalId: string,
mapperLocalId: string,
enabled: boolean,
) => void;
save: () => Promise<void>;
discard: () => void;
}
export function useAttributeMappingStore(): AttributeMappingStore {
const queryClient = useQueryClient();
const groupsQuery = useListSpanMapperGroups();
const serverGroups: MapperGroup[] = useMemo(
() => groupsQuery.data?.data?.items ?? [],
[groupsQuery.data],
);
const mapperQueries = useQueries(
serverGroups.map((group) =>
getListSpanMappersQueryOptions({ groupId: group.id }),
),
);
const mappersReady = mapperQueries.every((query) => !query.isLoading);
const ready = !groupsQuery.isLoading && mappersReady;
// Stable signature so the snapshot only rebuilds when server data changes.
const dataSignature = useMemo(
() =>
JSON.stringify(serverGroups) +
JSON.stringify(mapperQueries.map((query) => query.data?.data?.items ?? [])),
[serverGroups, mapperQueries],
);
const snapshot = useMemo<DraftGroup[]>(() => {
if (!ready) {
return [];
}
return serverGroups.map((group, index) =>
buildDraftGroup(
group,
(mapperQueries[index]?.data?.data?.items ?? []) as unknown as Mapper[],
),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, dataSignature]);
const [draft, setDraft] = useState<DraftGroup[] | null>(null);
// Initialise the working copy once data is ready (and re-init after a save
// clears it). Never clobbers in-flight edits — only runs when draft is null.
useEffect(() => {
if (ready && draft === null) {
setDraft(clone(snapshot));
}
}, [ready, draft, snapshot]);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createGroup } = useCreateSpanMapperGroup();
const { mutateAsync: updateGroup } = useUpdateSpanMapperGroup();
const { mutateAsync: deleteGroup } = useDeleteSpanMapperGroup();
const { mutateAsync: createMapper } = useCreateSpanMapper();
const { mutateAsync: updateMapper } = useUpdateSpanMapper();
const { mutateAsync: deleteMapper } = useDeleteSpanMapper();
const mutations: SaveMutations = useMemo(
() => ({
createGroup: async (data): Promise<string> => {
const result = await createGroup({ data });
return result.data.id;
},
updateGroup: async (groupId, data): Promise<void> => {
await updateGroup({ pathParams: { groupId }, data });
},
deleteGroup: async (groupId): Promise<void> => {
await deleteGroup({ pathParams: { groupId } });
},
createMapper: async (groupId, data): Promise<void> => {
await createMapper({ pathParams: { groupId }, data });
},
updateMapper: async (groupId, mapperId, data): Promise<void> => {
await updateMapper({ pathParams: { groupId, mapperId }, data });
},
deleteMapper: async (groupId, mapperId): Promise<void> => {
await deleteMapper({ pathParams: { groupId, mapperId } });
},
}),
[
createGroup,
updateGroup,
deleteGroup,
createMapper,
updateMapper,
deleteMapper,
],
);
const upsertGroup = useCallback((groupDraft: GroupDraft): void => {
setDraft((prev) => {
const groups = prev ?? [];
if (groupDraft.id) {
return groups.map((group) =>
group.localId === groupDraft.id
? nodeFromGroupDraft(groupDraft, group)
: group,
);
}
return [...groups, nodeFromGroupDraft(groupDraft)];
});
}, []);
const removeGroup = useCallback((localId: string): void => {
setDraft((prev) => (prev ?? []).filter((group) => group.localId !== localId));
}, []);
const toggleGroup = useCallback((localId: string, enabled: boolean): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === localId ? { ...group, enabled } : group,
),
);
}, []);
const upsertMapper = useCallback(
(groupLocalId: string, mapperDraft: MapperDraft): void => {
setDraft((prev) =>
(prev ?? []).map((group) => {
if (group.localId !== groupLocalId) {
return group;
}
if (mapperDraft.id) {
return {
...group,
mappers: group.mappers.map((mapper) =>
mapper.localId === mapperDraft.id
? nodeFromMapperDraft(mapperDraft, mapper)
: mapper,
),
};
}
return {
...group,
mappers: [...group.mappers, nodeFromMapperDraft(mapperDraft)],
};
}),
);
},
[],
);
const removeMapper = useCallback(
(groupLocalId: string, mapperLocalId: string): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === groupLocalId
? {
...group,
mappers: group.mappers.filter(
(mapper) => mapper.localId !== mapperLocalId,
),
}
: group,
),
);
},
[],
);
const toggleMapper = useCallback(
(groupLocalId: string, mapperLocalId: string, enabled: boolean): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === groupLocalId
? {
...group,
mappers: group.mappers.map((mapper) =>
mapper.localId === mapperLocalId ? { ...mapper, enabled } : mapper,
),
}
: group,
),
);
},
[],
);
const discard = useCallback((): void => {
setSaveError(null);
setDraft(clone(snapshot));
}, [snapshot]);
const save = useCallback(async (): Promise<void> => {
if (!draft) {
return;
}
setIsSaving(true);
setSaveError(null);
try {
await persistDraft(snapshot, draft, mutations);
await queryClient.invalidateQueries({
predicate: (query) =>
typeof query.queryKey?.[0] === 'string' &&
(query.queryKey[0] as string).startsWith(GROUPS_KEY_PREFIX),
});
// Re-initialise the working copy from the freshly-fetched server data.
setDraft(null);
toast.success('Attribute mapping changes saved');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
toast.error(`Failed to save changes: ${message}`);
} finally {
setIsSaving(false);
}
}, [draft, snapshot, mutations, queryClient]);
const isDirty = useMemo(
() => draft !== null && JSON.stringify(draft) !== JSON.stringify(snapshot),
[draft, snapshot],
);
return {
groups: draft ?? [],
isLoading: !ready || draft === null,
isError: groupsQuery.isError,
isDirty,
isSaving,
saveError,
upsertGroup,
removeGroup,
toggleGroup,
upsertMapper,
removeMapper,
toggleMapper,
save,
discard,
};
}

View File

@@ -0,0 +1,40 @@
import { useCallback, useState } from 'react';
import { DraftGroup, GroupDraft, MapperDraftMode } from './types';
import { EMPTY_GROUP_DRAFT, groupDraftFromNode } from './utils';
interface UseGroupFormDrawerResult {
isOpen: boolean;
mode: MapperDraftMode;
draft: GroupDraft;
setDraft: (next: GroupDraft) => void;
openForAdd: () => void;
openForEdit: (group: DraftGroup) => void;
close: () => void;
}
// Form state for the group drawer. Persistence is staged through the store,
// so this hook only owns open/draft/mode.
export function useGroupFormDrawer(): UseGroupFormDrawerResult {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<MapperDraftMode>('add');
const [draft, setDraft] = useState<GroupDraft>(EMPTY_GROUP_DRAFT);
const openForAdd = useCallback((): void => {
setMode('add');
setDraft(EMPTY_GROUP_DRAFT);
setIsOpen(true);
}, []);
const openForEdit = useCallback((group: DraftGroup): void => {
setMode('edit');
setDraft(groupDraftFromNode(group));
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
}, []);
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
}

View File

@@ -0,0 +1,40 @@
import { useCallback, useState } from 'react';
import { DraftMapper, MapperDraft, MapperDraftMode } from './types';
import { EMPTY_MAPPER_DRAFT, mapperDraftFromNode } from './utils';
interface UseMapperFormDrawerResult {
isOpen: boolean;
mode: MapperDraftMode;
draft: MapperDraft;
setDraft: (next: MapperDraft) => void;
openForAdd: () => void;
openForEdit: (mapper: DraftMapper) => void;
close: () => void;
}
// Form state for the mapper drawer. Persistence is staged through the store,
// so this hook only owns open/draft/mode.
export function useMapperFormDrawer(): UseMapperFormDrawerResult {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<MapperDraftMode>('add');
const [draft, setDraft] = useState<MapperDraft>(EMPTY_MAPPER_DRAFT);
const openForAdd = useCallback((): void => {
setMode('add');
setDraft(EMPTY_MAPPER_DRAFT);
setIsOpen(true);
}, []);
const openForEdit = useCallback((mapper: DraftMapper): void => {
setMode('edit');
setDraft(mapperDraftFromNode(mapper));
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
}, []);
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
}

View File

@@ -0,0 +1,262 @@
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import { v4 as uuid } from 'uuid';
import {
ConditionFilter,
DraftGroup,
DraftMapper,
FieldContext,
GroupDraft,
Mapper,
MapperDraft,
MapperGroup,
MapperOperation,
SourceConfig,
} from './types';
// Client-side id for not-yet-persisted rows. Prefixed so it never collides
// with a server UUID and is easy to spot in logs.
export function genLocalId(prefix: 'group' | 'mapper'): string {
return `local-${prefix}-${uuid()}`;
}
// Trimmed, de-duplicated, non-empty keys preserving input order.
export function cleanKeys(keys: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
keys.forEach((raw) => {
const key = raw.trim();
if (key && !seen.has(key)) {
seen.add(key);
result.push(key);
}
});
return result;
}
// Display clauses for a group's staged condition keys (span attribute keys
// first, then resource keys).
export function conditionFiltersFromGroup(group: {
attributes: string[];
resource: string[];
}): ConditionFilter[] {
return [
...group.attributes.map((key) => ({ context: 'attribute' as const, key })),
...group.resource.map((key) => ({ context: 'resource' as const, key })),
];
}
// Source configs for a mapper, highest priority first (first match wins at
// evaluation time).
export function getMapperSources(mapper: Mapper): SourceConfig[] {
const sources = mapper.config?.sources ?? [];
return [...sources]
.sort((a, b) => b.priority - a.priority)
.map((source) => ({
key: source.key,
context: source.context,
operation: source.operation,
}));
}
// A blank source row. New sources default to `move` so the original key is
// removed once standardized (the PRD default — minimizes duplication).
export function createEmptySource(): SourceConfig {
return {
key: '',
context: FieldContext.attribute,
operation: MapperOperation.move,
};
}
export function formatTimestamp(iso?: string): string {
if (!iso) {
return '—';
}
return dayjs(iso).format('MMM D, YYYY HH:mm');
}
export const EMPTY_MAPPER_DRAFT: MapperDraft = {
id: null,
name: '',
fieldContext: FieldContext.attribute,
sources: [createEmptySource()],
enabled: true,
};
// Trimmed, de-duplicated (by key), non-empty sources in priority order,
// preserving each source's context and operation.
export function getCleanSources(draft: MapperDraft): SourceConfig[] {
const seen = new Set<string>();
const result: SourceConfig[] = [];
draft.sources.forEach((source) => {
const key = source.key.trim();
if (key && !seen.has(key)) {
seen.add(key);
result.push({ ...source, key });
}
});
return result;
}
export function isMapperDraftValid(draft: MapperDraft): boolean {
return draft.name.trim().length > 0 && getCleanSources(draft).length > 0;
}
// Priority is derived from list order so the first row wins.
function buildSources(
draft: MapperDraft,
): SpantypesPostableSpanMapperDTO['config']['sources'] {
const sources = getCleanSources(draft);
return sources.map((source, index) => ({
key: source.key,
context: source.context,
operation: source.operation,
priority: sources.length - index,
}));
}
export function buildPostableMapper(
draft: MapperDraft,
): SpantypesPostableSpanMapperDTO {
return {
name: draft.name.trim(),
fieldContext: draft.fieldContext,
enabled: draft.enabled,
config: { sources: buildSources(draft) },
};
}
// The target name is immutable on update (UpdatableSpanMapper has no name).
export function buildUpdatableMapper(
draft: MapperDraft,
): SpantypesUpdatableSpanMapperDTO {
return {
fieldContext: draft.fieldContext,
enabled: draft.enabled,
config: { sources: buildSources(draft) },
};
}
export const EMPTY_GROUP_DRAFT: GroupDraft = {
id: null,
name: '',
attributes: [''],
resource: [],
enabled: true,
};
export function isGroupDraftValid(draft: GroupDraft): boolean {
return draft.name.trim().length > 0;
}
export function buildPostableGroup(
draft: GroupDraft,
): SpantypesPostableSpanMapperGroupDTO {
return {
name: draft.name.trim(),
enabled: draft.enabled,
condition: {
attributes: cleanKeys(draft.attributes),
resource: cleanKeys(draft.resource),
},
};
}
// A full group payload is also a valid partial-update payload (all updatable
// fields are present), so we reuse the postable builder.
export function buildUpdatableGroup(
draft: GroupDraft,
): SpantypesUpdatableSpanMapperGroupDTO {
return buildPostableGroup(draft);
}
// ---- working-copy (draft tree) helpers ----
export function buildDraftMapper(mapper: Mapper): DraftMapper {
return {
localId: mapper.id,
serverId: mapper.id,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources: getMapperSources(mapper),
enabled: mapper.enabled,
};
}
export function buildDraftGroup(
group: MapperGroup,
mappers: Mapper[],
): DraftGroup {
return {
localId: group.id,
serverId: group.id,
name: group.name,
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
enabled: group.enabled,
mappers: mappers.map(buildDraftMapper),
};
}
// DraftGroup -> editable form state (id carries the localId).
export function groupDraftFromNode(group: DraftGroup): GroupDraft {
return {
id: group.localId,
name: group.name,
attributes: group.attributes.length > 0 ? group.attributes : [''],
resource: group.resource,
enabled: group.enabled,
};
}
// DraftMapper -> editable form state (id carries the localId).
export function mapperDraftFromNode(mapper: DraftMapper): MapperDraft {
return {
id: mapper.localId,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources:
mapper.sources.length > 0
? mapper.sources.map((source) => ({ ...source }))
: [createEmptySource()],
enabled: mapper.enabled,
};
}
// Form state -> working-copy node. Reuses cleanKeys/getCleanSourceKeys so the
// staged tree already holds normalized values.
export function nodeFromGroupDraft(
draft: GroupDraft,
existing?: DraftGroup,
): DraftGroup {
return {
localId: existing?.localId ?? genLocalId('group'),
serverId: existing?.serverId ?? null,
name: draft.name.trim(),
attributes: cleanKeys(draft.attributes),
resource: cleanKeys(draft.resource),
enabled: draft.enabled,
mappers: existing?.mappers ?? [],
};
}
export function nodeFromMapperDraft(
draft: MapperDraft,
existing?: DraftMapper,
): DraftMapper {
return {
localId: existing?.localId ?? genLocalId('mapper'),
serverId: existing?.serverId ?? null,
name: draft.name.trim(),
fieldContext: draft.fieldContext,
sources: getCleanSources(draft),
enabled: draft.enabled,
};
}

View File

@@ -0,0 +1,172 @@
.llm-observability-model-pricing {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 32px;
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
&__title {
h1 {
margin: 0;
font-size: 22px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: var(--bg-vanilla-400);
font-size: 13px;
}
}
&__actions {
display: flex;
gap: 8px;
}
}
.page-tabs {
.ant-tabs-nav {
margin-bottom: 0;
}
}
.filters-bar {
display: flex;
gap: 12px;
align-items: center;
&__search {
flex: 1;
max-width: 360px;
}
&__source,
&__currency {
min-width: 160px;
}
&__add {
margin-left: auto;
}
}
.page-error {
padding: 12px 16px;
border-radius: 4px;
background: rgba(255, 90, 90, 0.08);
color: var(--bg-cherry-400);
font-size: 13px;
}
.page-pagination {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.page-footer {
color: var(--bg-vanilla-400);
font-size: 12px;
}
}
.model-costs-table {
.ant-table-thead > tr > th {
color: var(--bg-vanilla-400) !important;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ant-table-tbody > tr > td {
color: var(--bg-vanilla-400);
}
.model-cell {
display: flex;
flex-direction: column;
gap: 2px;
// Allow the flex children to shrink below their content width so the
// table's fixed-layout / nowrap cells truncate instead of overflowing
// into the Provider column.
min-width: 0;
&__name {
color: var(--bg-vanilla-100);
font-weight: 600;
}
&__name,
&__canonical-id {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__canonical-id {
color: var(--bg-vanilla-400);
font-family: var(--code-font-family, monospace);
font-size: 12px;
}
}
.price-cell {
font-family: var(--code-font-family, monospace);
color: var(--bg-vanilla-400);
}
.extra-buckets {
display: flex;
flex-wrap: wrap;
gap: 6px;
&__chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
}
&__key {
font-family: var(--code-font-family, monospace);
font-size: 12px;
}
&__price {
font-family: var(--code-font-family, monospace);
font-weight: 600;
}
}
.muted {
color: var(--bg-vanilla-400);
}
.source-badge {
margin: 0;
&--auto {
background: rgba(78, 116, 248, 0.12);
color: var(--bg-robin-400);
border-color: rgba(78, 116, 248, 0.24);
}
&--override {
background: rgba(245, 175, 25, 0.12);
color: var(--bg-amber-400);
border-color: rgba(245, 175, 25, 0.24);
}
}
&__row--selected {
background: rgba(78, 116, 248, 0.06);
}
}

View File

@@ -0,0 +1,180 @@
import { useMemo, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Pagination } from '@signozhq/ui/pagination';
import { SelectSimple } from '@signozhq/ui/select';
import { Tabs } from '@signozhq/ui/tabs';
import { Plus, Search } from '@signozhq/icons';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import ModelCostDrawer from './ModelCostDrawer';
import ModelCostsTable from './ModelCostsTable';
import { useModelCostDrawer } from './useModelCostDrawer';
import type { PricingRule, SourceFilter } from './types';
import { filterRules } from './utils';
import './LLMObservabilityModelPricing.styles.scss';
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: 'all', label: 'Source: All' },
{ value: 'auto', label: 'Auto-populated' },
{ value: 'override', label: 'User override' },
];
const CURRENCY_OPTIONS = [
{ value: 'USD', label: 'USD' },
{ value: 'EUR', label: 'EUR', disabled: true },
{ value: 'INR', label: 'INR', disabled: true },
];
const PAGE_SIZE = 20;
function LLMObservabilityModelPricing(): JSX.Element {
const [search, setSearch] = useState<string>('');
const [source, setSource] = useState<SourceFilter>('all');
const [currency, setCurrency] = useState<string>('USD');
const [page, setPage] = useState<number>(1);
const { data, isLoading, isError } = useListLLMPricingRules({
offset: (page - 1) * PAGE_SIZE,
limit: PAGE_SIZE,
});
const { user } = useAppContext();
const [canManagePricing] = useComponentPermission(
['manage_llm_pricing'],
user.role,
);
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
const total = data?.data?.total ?? 0;
const filteredRules = useMemo(
() => filterRules(rules, search, source),
[rules, search, source],
);
const drawer = useModelCostDrawer();
// Search/source filter the current page client-side (the list endpoint only
// supports offset/limit), so reset to the first page when they change.
const resetToFirstPage = (): void => setPage(1);
return (
<div
className="llm-observability-model-pricing"
data-testid="llm-observability-model-pricing-page"
>
<header className="page-header">
<div className="page-header__title">
<h1>Configuration</h1>
<p>Model pricing and cost estimation settings</p>
</div>
</header>
<Tabs
className="page-tabs"
defaultValue="model-costs"
items={[
{ key: 'model-costs', label: 'Model costs', children: null },
{
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
},
]}
/>
<div className="filters-bar">
<Input
className="filters-bar__search"
placeholder="Search by model or provider…"
prefix={<Search size={14} />}
value={search}
onChange={(event): void => {
setSearch(event.target.value);
resetToFirstPage();
}}
testId="search-input"
/>
<SelectSimple
className="filters-bar__source"
value={source}
onChange={(value): void => {
setSource(value as SourceFilter);
resetToFirstPage();
}}
items={SOURCE_OPTIONS}
testId="source-select"
/>
<SelectSimple
className="filters-bar__currency"
value={currency}
onChange={(value): void => setCurrency(value as string)}
items={CURRENCY_OPTIONS}
testId="currency-select"
/>
{canManagePricing && (
<Button
variant="solid"
color="primary"
className="filters-bar__add"
prefix={<Plus size={14} />}
onClick={(): void => drawer.openForAdd()}
testId="add-model-cost-btn"
>
Add model cost
</Button>
)}
</div>
{isError && (
<div className="page-error" role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
<ModelCostsTable
rules={filteredRules}
isLoading={isLoading}
selectedRuleId={drawer.selectedRuleId}
canManage={canManagePricing}
onEdit={drawer.openForEdit}
/>
{total > PAGE_SIZE && (
<Pagination
className="page-pagination"
total={total}
pageSize={PAGE_SIZE}
current={page}
onPageChange={setPage}
/>
)}
<footer className="page-footer">
Showing {filteredRules.length} of {total} model{total === 1 ? '' : 's'}
{' · '}All prices per 1M tokens (USD)
</footer>
<ModelCostDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
draft={drawer.draft}
setDraft={drawer.setDraft}
onClose={drawer.close}
onSave={drawer.save}
onDelete={drawer.deleteRule}
isSaving={drawer.isSaving}
isDeleting={drawer.isDeleting}
saveError={drawer.saveError}
canManage={canManagePricing}
/>
</div>
);
}
export default LLMObservabilityModelPricing;

View File

@@ -0,0 +1,313 @@
.model-cost-drawer {
// Uniform horizontal padding across header / body / footer. The header and
// footer read these dialog vars; the body (rendered in drawer-description)
// is set directly below.
--dialog-header-padding: 20px 24px;
--dialog-footer-padding: 16px 24px;
// The drawer body — children render inside [data-slot='drawer-description']
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
[data-slot='drawer-description'] {
display: flex;
flex-direction: column;
gap: 18px;
padding: 20px 24px;
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
display: flex;
overflow-y: auto;
&__title {
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: var(--bg-vanilla-400);
font-size: 12px;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
// Horizontal padding is provided by the drawer-footer slot var above.
padding: 0;
width: 100%;
&-right {
display: flex;
gap: 8px;
margin-left: auto;
}
}
.drawer-section {
display: flex;
flex-direction: column;
gap: 6px;
label,
.field-label {
font-size: 12px;
font-weight: 600;
color: var(--bg-vanilla-200);
}
.help {
margin: 0;
font-size: 11px;
}
}
.muted {
color: var(--bg-vanilla-400);
}
.required {
color: var(--bg-cherry-400);
}
.full-width {
width: 100%;
}
.drawer-surface {
padding: 14px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bg-slate-300);
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-300);
}
}
}
.managed-label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--bg-vanilla-400);
}
.pattern-box {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bg-slate-300);
}
.pattern-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
}
.pattern-chip {
display: inline-flex;
align-items: center;
gap: 4px;
&__remove {
background: transparent;
border: none;
padding: 0;
margin-left: 2px;
cursor: pointer;
color: inherit;
display: inline-flex;
align-items: center;
&:hover {
color: var(--bg-cherry-400);
}
}
}
.pattern-add {
display: flex;
gap: 6px;
}
.help {
code {
padding: 1px 4px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
font-family: var(--code-font-family, monospace);
font-size: 10px;
}
}
.source-radio-group {
// @signozhq/ui's RadioGroupItem defaults its unchecked border to
// --l3-background, which matches the drawer surface and makes the dot
// invisible. Override with a contrasting border so users can see the
// unchecked state.
--radio-group-item-border-color: var(--bg-slate-200);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
// Layout overrides for @signozhq/ui's RadioGroupItem wrapper. The
// library injects single-class CSS at runtime (after our bundled
// stylesheet loads), so we use a two-class selector to win the
// cascade and force the wrapper to lay the dot on the left with the
// label text flush beside it.
.source-radio {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.02);
margin: 0;
width: 100%;
// Include padding + border in the 100% width so the card fits inside
// the SOURCE surface instead of overflowing its right edge.
box-sizing: border-box;
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
// The radio button itself: keep it fixed-size and aligned with
// the title baseline (margin-top compensates for align-items:
// flex-start vs the title's line-box).
> button[role='radio'] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin-top: 3px;
}
// The library wraps children in a <label>. Make it grow into the
// remaining width and reset the .drawer-section label typography
// leak (set earlier in this file) so the title/desc divs use
// their own styles.
> label {
flex: 1 1 auto;
min-width: 0;
display: block;
text-align: left;
cursor: pointer;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
&__title {
font-weight: 600;
font-size: 13px;
color: var(--bg-vanilla-100);
}
&__desc {
margin-top: 2px;
font-size: 12px;
color: var(--bg-vanilla-400);
}
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
// Use :has() to highlight the wrapper card when its inner button is checked.
&.source-radio--auto:has(button[data-state='checked']) {
background: rgba(78, 116, 248, 0.1);
border-color: rgba(78, 116, 248, 0.3);
}
&.source-radio--override:has(button[data-state='checked']) {
background: rgba(245, 175, 25, 0.1);
border-color: rgba(245, 175, 25, 0.3);
}
&:hover {
background: rgba(255, 255, 255, 0.04);
}
}
}
.reset-confirm {
margin-top: 12px;
padding: 12px;
border-radius: 4px;
background: rgba(78, 116, 248, 0.06);
border: 1px solid rgba(78, 116, 248, 0.2);
p {
margin: 0 0 10px;
font-size: 12px;
}
&__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
.pricing-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.pricing-field {
display: flex;
flex-direction: column;
gap: 4px;
input {
width: 100%;
}
}
.cache-mode-field {
margin-top: 10px;
}
.extras-divider {
margin-top: 14px;
margin-bottom: 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-400);
}
.drawer-error {
padding: 10px 12px;
border-radius: 4px;
background: rgba(255, 90, 90, 0.08);
color: var(--bg-cherry-400);
font-size: 12px;
}
}

View File

@@ -0,0 +1,176 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Trash2 } from '@signozhq/icons';
import PatternEditor from './PatternEditor';
import PricingFields from './PricingFields';
import SourceSelector from './SourceSelector';
import { PROVIDER_OPTIONS } from './constants';
import { validateDraft } from './utils';
import type { DrawerDraft, DrawerMode } from './types';
import './ModelCostDrawer.styles.scss';
interface ModelCostDrawerProps {
isOpen: boolean;
mode: DrawerMode;
draft: DrawerDraft;
setDraft: (next: DrawerDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
canManage: boolean;
}
function ModelCostDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
canManage,
}: ModelCostDrawerProps): JSX.Element {
// Metadata (model id / provider / patterns / source) is editable by any
// manager. Pricing fields are editable only once the user picks "User
// override" — auto-populated pricing is managed by SigNoz. Write APIs are
// Admin-only, so non-managers can't edit anything.
const metadataReadOnly = !canManage;
const pricingReadOnly = !canManage || !draft.isOverride;
const validation = validateDraft(draft, mode);
const showValidationTooltip =
canManage && !validation.ok && !!validation.message;
const update = (patch: Partial<DrawerDraft>): void => {
setDraft({ ...draft, ...patch });
};
const footer = (
<div className="model-cost-drawer__footer">
{mode === 'edit' && canManage && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
loading={isDeleting}
testId="drawer-delete-btn"
>
Delete
</Button>
)}
<div className="model-cost-drawer__footer-right">
<Button
variant="outlined"
color="secondary"
onClick={onClose}
testId="drawer-cancel-btn"
>
{canManage ? 'Cancel' : 'Close'}
</Button>
{canManage && (
<TooltipSimple
title={showValidationTooltip ? validation.message : ''}
withPortal={false}
>
{/* span wrapper so the tooltip fires even when the button is disabled */}
<span className="model-cost-drawer__save-wrap">
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!validation.ok}
testId="drawer-save-btn"
>
Save
</Button>
</span>
</TooltipSimple>
)}
</div>
</div>
);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
width="base"
className="model-cost-drawer"
footer={footer}
title={mode === 'edit' ? 'Edit model cost' : 'Add model cost'}
subTitle="Pricing computes gen_ai.estimated_total_cost at ingest."
drawerHeaderProps={{ className: 'model-cost-drawer__title' }}
>
<div className="drawer-section">
<label htmlFor="billing-model-id">Billing model ID</label>
<Input
id="billing-model-id"
placeholder="e.g. openai:gpt-4o"
value={draft.modelName}
disabled={mode === 'edit' || metadataReadOnly}
onChange={(e): void => update({ modelName: e.target.value })}
testId="drawer-model-id-input"
/>
</div>
<div className="drawer-section">
<label htmlFor="provider-select">Provider</label>
<SelectSimple
id="provider-select"
value={draft.provider}
onChange={(value): void => update({ provider: value as string })}
items={PROVIDER_OPTIONS}
disabled={mode === 'edit' || metadataReadOnly}
className="full-width"
withPortal={false}
testId="drawer-provider-select"
/>
</div>
<PatternEditor
patterns={draft.patterns}
isReadOnly={metadataReadOnly}
onChange={(patterns): void => update({ patterns })}
/>
<SourceSelector
isOverride={draft.isOverride}
isReadOnly={metadataReadOnly}
onChange={(isOverride): void => update({ isOverride })}
/>
<PricingFields
pricing={draft.pricing}
isReadOnly={pricingReadOnly}
onChange={(patch): void =>
setDraft({ ...draft, pricing: { ...draft.pricing, ...patch } })
}
/>
{saveError && (
<div className="drawer-error" role="alert">
{saveError}
</div>
)}
</DrawerWrapper>
);
}
export default ModelCostDrawer;

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