Compare commits

..

19 Commits

Author SHA1 Message Date
Nikhil Soni
3c85421f23 chore: remove flamegraph v2 since we've moved to v4 now
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:15:29 +05:30
Nikhil Soni
bcacc3d8d7 chore: remove waterfall v2 since we've moved to v4 now 2026-06-09 19:38:07 +05:30
Nikhil Soni
51a7789a29 chore: update openapi specs 2026-06-09 19:22:56 +05:30
Nikhil Soni
19078a9c7f chore: remove select all limit from waterfall request
It was added for testing only and fine tuning the limit value
2026-06-09 19:19:26 +05:30
Nikhil Soni
5a755f54b5 chore: cleanup waterfall v3 api
We've moved to v4 as part of memory allocation optimisation
2026-06-09 19:19:26 +05:30
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
Nikhil Mantri
9ad37ad6e6 feat(metrics-explorer): fast path for get stats and get treemap when no filter (#11458)
* chore: added fastPath

* chore: rearrange and readable

* chore: build fix

* chore: combining two functions into a common flow

* chore: variable name update:

* feat(metrics-explorer): fast path for get treemap in case of no filter (#11459)

* chore: fastpath for treemap

* chore: added fastpath function

* chore: refactor fir common code

* chore: refactor changes

* chore: added tests for get stats and get treemap
2026-06-09 12:48:15 +00:00
Nikhil Mantri
83b2cabbcd feat(infra-monitoring): v2 namespaces integration tests (#11429)
* 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: 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): 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>

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

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 11:43:22 +00:00
swapnil-signoz
922800ff98 feat: adding support for Azure Container Apps (#11580) 2026-06-09 09:58:26 +00:00
Nikhil Mantri
2d3772ef10 feat(infra-monitoring): v2 nodes integration tests (#11428)
* 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

* ci: register inframonitoring suite + ruff format 01_hosts

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 09:47:08 +00:00
Nikhil Mantri
40e5cb4467 feat(infra-monitoring): v2 pods integration tests (#11424)
* 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

* ci: register inframonitoring suite + ruff format 01_hosts

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

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

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

* test(infra-monitoring): drop dangling generator-script reference in pods tests

pods_phases.jsonl comment referenced tests/gen_pods_datasets.py, which was
never committed. Reword to describe the committed dataset instead.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 09:27:06 +00:00
swapnil-signoz
b072095a9d feat: adding support for Azure AKS (#11615)
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 support for Azure AKS

* refactor: updating metric names in integration config

* chore: generating OpenAPI spec

* chore: adding note in overview

* refactor: updating spatial aggregation and other names

* refactor: updating reduceTo to empty value

* refactor: updating title
2026-06-09 09:13:38 +00:00
Nikhil Mantri
303908542a feat(infra-monitoring): v2 host endpoint integration tests (#11418)
* 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: fix for surfacing meta for pods custom group by

* ci: register inframonitoring suite + ruff format 01_hosts

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

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

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 05:43:39 +00:00
Abhi kumar
f52ce461d0 feat: generalizes the Legend component so it can be reused by non‑uPlot charts, and adds a shared Pie (donut) chart built on top of it (#11583)
* refactor(uPlotV2): split Legend into presentational + uPlot controller

Separate the legend into a presentational `Legend` (renders supplied items +
delegated handlers, with search/copy/virtualization) and a `UPlotLegend`
controller that wires it to the uPlot config via useLegendsSync/useLegendActions.
This lets non-uPlot charts reuse the legend. ChartWrapper now renders UPlotLegend
(behaviour unchanged for TimeSeries/Bar/Histogram). Also fixes the copy-button
hover layout shift (reserve space, fade via opacity) and uses @signozhq/ui
tooltips for legend items.

* feat(charts): add shared Pie chart component

Add a reusable @visx donut Pie chart (charts/Pie) that consumes the shared
presentational Legend: chart/legend split via calculateChartDimensions,
search/copy, hover-sync, slice hide/unhide (mirrors the uPlot legend) with
localStorage persistence, leader labels and a centre total. Split into PieArc /
PieCenterLabel / usePieInteractions for readability.

Also renames the tooltip click payload TooltipClickData -> ChartClickData across
the TooltipPlugin and chart prop types (the type describes any chart click, not
just tooltips).

* test(charts): add Pie chart + usePieInteractions tests

Cover the shared Pie chart and its helpers/hook:
- utils: scaled font size, arc geometry, colour lighten/fill dimming
- usePieInteractions: marker toggle, label isolate/reset, hover focus,
  hidden-slice guard, localStorage persistence + rehydration
- PieArc: leader-label threshold, label truncation, enter/leave/click
- PieCenterLabel: numeric/unit split
- Pie: no-data state, arc-per-slice rendering, legend, layout per position,
  hide-on-marker-click

Adds a global ResizeObserver polyfill to jest.setup.ts (alongside the existing
IntersectionObserver one) since visx's useTooltipInPortal requires it.

* chore: pr review fixes

* chore: pr review comments

* chore: pr review comments
2026-06-09 05:01:52 +00:00
SagarRajput-7
2e8aa0f42d fix(sso): strip conditional google auth and role mapping fields from save payload (#11613)
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-08 20:16:19 +00:00
Nikhil Soni
c688170a13 feat(trace-detail): add flamegraph v3 with optimized memory consumption (part 1) (#11462)
* feat: add types for flamegraph v3 in module structure

* chore: remove limit from request payload

It's a new api so doesn't need to be backward compatible

* feat: add config for flamegraph

* feat: add method to enrich selected spans

* feat: add api and module for flamegraph v3

* feat: query full spans for smaller traces

* chore: move exported methods to the top

* chore: ignore nil assigment lint error

* chore: extract out flamegraph building logic for easier review

* chore: update openapi specs

* chore: move flamegraph after aggregation to reduce diff

* chore: remove un related changes to keep diff minimum

* chore: avoid passing request type to module

* feat: return selected fields only in flamegraph

And reduce the no. of fields scanned from db

* chore: make array fields required and non nullable

* chore: update openapi specs

* chore: mark all fields in response as required

* refactor: switch to using group by instead of distinct on

Since group by is faster is enough no. of threads are used

* chore: remove service name root level field from flamegraph span

* chore: update openapi specs

* fix: update alias in order by of the query

* chore: remove unnecessary nil check

* fix: set empty array for missing data

* chore: use single line for error creation

* chore: mark response fields as required

* chore: update openapi specs

* chore: add test to verify the flamegraph query sql

* chore: update openapi specs

* chore: use orderbyasc instead of order by
2026-06-08 17:29:30 +00:00
Vikrant Gupta
7844fc1fe1 fix(authn): include base path in SSO callback and error-redirect URLs (#11588)
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(authn): include base path in SSO callback and error-redirect URLs

The SAML ACS URL and the OIDC/Google redirect URLs were built from the
site URL host plus a hardcoded path (e.g. /api/v1/complete/saml), dropping
the base path. When SigNoz is served under a sub-path (global.external_url
with a path, e.g. https://example.com/signoz), the API is served at
<prefix>/api/v1/complete/<provider>, so the identity provider was told to
call back to a path without the prefix and hit a 404.

Thread global.Config into the SAML/OIDC/Google callback providers and the
session handler, and prepend global.Config.ExternalPath() to the callback
paths and the SSO error redirect to /login. Root deployments are
unchanged since ExternalPath() returns "" without a configured sub-path.

* fix(authn): run callbackauthn suite with base path

* refactor(tests): self-contained base-path fixture for callbackauthn

Move the base-path setup out of the shared create_signoz factory and into
a package-scoped signoz fixture in the callbackauthn suite's own conftest
(same pattern as rootuser/conftest.py). When --base-path is set the fixture
appends SIGNOZ_GLOBAL_EXTERNAL__URL and the url-config prefix locally;
without it it behaves exactly like the global fixture. The shared factory
and docker config are left untouched.

* test(authn): add base-path SSO integration suite

Adds a dedicated `basepath` integration suite that serves SigNoz under a
hardcoded /signoz prefix (SIGNOZ_GLOBAL_EXTERNAL__URL) and exercises the SAML
and OIDC happy-path logins end-to-end. Every SigNoz API call is issued under
the prefix and the IdP callback (ACS / redirect URI) is registered with the
prefix, so the flow only passes when the backend builds prefixed callback URLs.

The shared TestContainerUrlConfig and create_signoz factory are left untouched.
The suite's conftest shadows the same-named auth fixtures (create_user_admin,
get_token, get_session_context, apply_license) with base-path-aware variants and
reuses the Keycloak/browser fixtures, which are not under the base path.

Google SSO is not covered: it requires the real accounts.google.com issuer and
a real Google login, so it cannot run against the local Keycloak IdP; it shares
the identical path.Join(ExternalPath, redirectPath) callback logic that SAML
and OIDC validate.

* revert: drop in-place base-path wiring from integration harness

Removes the --base-path flag, TestContainerUrlConfig.base_path, the idp.py and
02_saml.py .get() changes, and the callbackauthn base-path conftest fixture.
Base-path SSO is now covered by the dedicated `basepath` suite, so the shared
harness (TestContainerUrlConfig, create_signoz, callbackauthn) is back to its
original root-only form.

* refactor(test): remove apply_license fixture

* refactor(test): extract base-path-aware auth factories

Extract the session-context / token / token-pair / admin-registration logic
in fixtures/auth.py into reusable factory functions that take an optional
base_path (token_getter, session_context_getter, tokens_getter, register_admin),
with the fixtures delegating to them. Default base_path="" is byte-identical for
existing callers.

The basepath suite's conftest now reuses these factories with the /signoz prefix
as thin one-line fixture overrides instead of duplicating the request logic.

* refactor(test): give base-path admin registration a distinct cache key

register_admin takes an optional cache_key (default "create_user_admin"); the
basepath suite passes a distinct key so that under --reuse the admin marker
cached against the signoz-base-path container is not restored for (or from)
other suites' default signoz instance.
2026-06-08 14:15:04 +00:00
Vinicius Lourenço
e02da843f2 fix(infra-monitoring-charts): fixes for hosts/deployments/jobs/namespaces (#11599)
* fix(infra-monitoring-jobs): title of the chart misleading

Ref: https://github.com/SigNoz/engineering-pod/issues/5211#issuecomment-4619888389

* fix(infra-monitoring-namespaces): wrong limit & using wrong filter for statefulsets

Ref: https://github.com/SigNoz/engineering-pod/issues/5211#issuecomment-4619023361

* feat(infra-monitoring-hosts): add new chart based on operations time

Ref: https://github.com/SigNoz/engineering-pod/issues/5211#issue-4578950064

* feat(infra-monitoring-hosts): add group by on chart for system disk io

Ref: https://github.com/SigNoz/engineering-pod/issues/5211#issuecomment-4611797867

* fix(infra-monitoring-hosts): chart for disk operations using the wrong metric

Ref: https://github.com/SigNoz/engineering-pod/issues/5211#issue-4578950064

* fix(infra-monitoring-deployments): little typo in the chart name

* fix(volumes): ensure the name/type are standard based on the metric type
2026-06-08 11:45:27 +00:00
168 changed files with 12079 additions and 7386 deletions

View File

@@ -39,10 +39,12 @@ jobs:
matrix:
suite:
- alerts
- basepath
- callbackauthn
- cloudintegrations
- dashboard
- ingestionkeys
- inframonitoring
- logspipelines
- passwordauthn
- preference
@@ -83,7 +85,7 @@ jobs:
run: |
cd tests && uv sync
- name: webdriver
if: matrix.suite == 'callbackauthn'
if: matrix.suite == 'callbackauthn' || matrix.suite == 'basepath'
run: |
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list

View File

@@ -91,7 +91,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
sqlstoreProviderFactories(),
signoz.NewTelemetryStoreProviderFactories(),
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
return signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, config authz.Config, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore, config)

View File

@@ -107,17 +107,17 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
sqlstoreProviderFactories(),
signoz.NewTelemetryStoreProviderFactories(),
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing)
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing, config.Global)
if err != nil {
return nil, err
}
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings)
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings, config.Global)
if err != nil {
return nil, err
}
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing)
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
if err != nil {
return nil, err
}

View File

@@ -440,6 +440,17 @@ traces:
max_depth_to_auto_expand: 5
# Threshold below which all spans are returned without windowing.
max_limit_to_select_all_spans: 10000
flamegraph:
# Maximum number of BFS depth levels included in a windowed response.
max_selected_levels: 50
# Maximum spans per level before sampling is applied.
max_spans_per_level: 100
# Number of highest-latency spans always included when sampling a level.
sampling_top_latency_count: 5
# Number of timestamp buckets used for uniform sampling within a level.
sampling_bucket_count: 50
# Threshold below which all spans are returned without windowing or sampling.
select_all_spans_limit: 100000
##################### Authz #################################
authz:

View File

@@ -1,6 +0,0 @@
services:
signoz:
environment:
# Enable dashboard v2 (maps to flagger.config.boolean.use_dashboard_v2: true)
# Double underscore is the key separator; single underscore stays part of the key.
- SIGNOZ_FLAGGER__CONFIG__BOOLEAN__USE_DASHBOARD_V2=true

View File

@@ -1360,6 +1360,8 @@ components:
- sqs
- storageaccountsblob
- cdnprofile
- containerapp
- aks
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -6638,6 +6640,70 @@ components:
- attribute
- resource
type: string
SpantypesFlamegraphSpan:
properties:
attributes:
additionalProperties: {}
type: object
durationNano:
minimum: 0
type: integer
event:
items:
$ref: '#/components/schemas/SpantypesEvent'
type: array
hasError:
type: boolean
level:
format: int64
type: integer
name:
type: string
parentSpanId:
type: string
resource:
additionalProperties:
type: string
type: object
spanId:
type: string
timestamp:
minimum: 0
type: integer
required:
- spanId
- parentSpanId
- timestamp
- durationNano
- hasError
- name
- level
- event
- attributes
- resource
type: object
SpantypesGettableFlamegraphTrace:
properties:
endTimestampMillis:
format: int64
type: integer
hasMore:
type: boolean
spans:
items:
items:
$ref: '#/components/schemas/SpantypesFlamegraphSpan'
type: array
type: array
startTimestampMillis:
format: int64
type: integer
required:
- spans
- startTimestampMillis
- endTimestampMillis
- hasMore
type: object
SpantypesGettableSpanMapperGroups:
properties:
items:
@@ -6658,11 +6724,6 @@ components:
type: object
SpantypesGettableWaterfallTrace:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregationResult'
nullable: true
type: array
endTimestampMillis:
minimum: 0
type: integer
@@ -6703,6 +6764,15 @@ components:
traceId:
type: string
type: object
SpantypesPostableFlamegraph:
properties:
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
selectedSpanId:
type: string
type: object
SpantypesPostableSpanMapper:
properties:
config:
@@ -6741,14 +6811,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:
@@ -20535,12 +20597,11 @@ paths:
summary: Put profile in Zeus for a deployment.
tags:
- zeus
/api/v3/traces/{traceID}/waterfall:
/api/v3/traces/{traceID}/flamegraph:
post:
deprecated: false
description: Returns the waterfall view of spans for a given trace ID with tree
structure, metadata, and windowed pagination
operationId: GetWaterfall
description: Returns the flamegraph view of spans for a given trace ID.
operationId: GetFlamegraph
parameters:
- in: path
name: traceID
@@ -20551,7 +20612,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableWaterfall'
$ref: '#/components/schemas/SpantypesPostableFlamegraph'
responses:
"200":
content:
@@ -20559,7 +20620,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableWaterfallTrace'
$ref: '#/components/schemas/SpantypesGettableFlamegraphTrace'
status:
type: string
required:
@@ -20602,7 +20663,7 @@ paths:
- VIEWER
- tokenizer:
- VIEWER
summary: Get waterfall view for a trace
summary: Get flamegraph view for a trace
tags:
- tracedetail
/api/v4/traces/{traceID}/waterfall:

View File

@@ -5,10 +5,12 @@ import (
"fmt"
"log/slog"
"net/url"
"path"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -26,13 +28,14 @@ var defaultScopes []string = []string{"email", "profile", oidc.ScopeOpenID}
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
settings factory.ScopedProviderSettings
store authtypes.AuthNStore
licensing licensing.Licensing
httpClient *client.Client
settings factory.ScopedProviderSettings
store authtypes.AuthNStore
licensing licensing.Licensing
httpClient *client.Client
globalConfig global.Config
}
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings) (*AuthN, error) {
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings, globalConfig global.Config) (*AuthN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn")
httpClient, err := client.New(providerSettings.Logger, providerSettings.TracerProvider, providerSettings.MeterProvider)
@@ -41,10 +44,11 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
}
return &AuthN{
settings: settings,
store: store,
licensing: licensing,
httpClient: httpClient,
settings: settings,
store: store,
licensing: licensing,
httpClient: httpClient,
globalConfig: globalConfig,
}, nil
}
@@ -197,7 +201,7 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
RedirectURL: (&url.URL{
Scheme: siteURL.Scheme,
Host: siteURL.Host,
Path: redirectPath,
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
}).String(),
}, nil
}

View File

@@ -6,10 +6,12 @@ import (
"encoding/base64"
"encoding/pem"
"net/url"
"path"
"strings"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -24,14 +26,16 @@ const (
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
store authtypes.AuthNStore
licensing licensing.Licensing
store authtypes.AuthNStore
licensing licensing.Licensing
globalConfig global.Config
}
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing) (*AuthN, error) {
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing, globalConfig global.Config) (*AuthN, error) {
return &AuthN{
store: store,
licensing: licensing,
store: store,
licensing: licensing,
globalConfig: globalConfig,
}, nil
}
@@ -132,7 +136,7 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
return nil, err
}
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: redirectPath}
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: path.Join(a.globalConfig.ExternalPath(), redirectPath)}
// Note:
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.

View File

@@ -41,6 +41,15 @@ if (typeof window.IntersectionObserver === 'undefined') {
(window as any).IntersectionObserver = IntersectionObserverMock;
}
if (typeof window.ResizeObserver === 'undefined') {
class ResizeObserverMock {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
(window as any).ResizeObserver = ResizeObserverMock;
}
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),

View File

@@ -641,103 +641,6 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* Returns a page of v2-shape dashboards for the calling user's org. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`title`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). Pinned dashboards float to the top of each page.
* @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)

View File

@@ -2651,6 +2651,8 @@ export enum CloudintegrationtypesServiceIDDTO {
sqs = 'sqs',
storageaccountsblob = 'storageaccountsblob',
cdnprofile = 'cdnprofile',
containerapp = 'containerapp',
aks = 'aks',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -4681,61 +4683,6 @@ export interface DashboardtypesGettableDashboardV2DTO {
updatedBy?: string;
}
export interface DashboardtypesGettableDashboardWithPinDTO {
/**
* @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: DashboardtypesDashboardSpecDTO;
/**
* @type array,null
*/
tags: TagtypesPostableTagDTO[] | null;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesGettablePublicDasbhboardDTO {
/**
* @type string
@@ -7824,6 +7771,77 @@ export enum SpantypesFieldContextDTO {
attribute = 'attribute',
resource = 'resource',
}
export type SpantypesFlamegraphSpanDTOAttributes = { [key: string]: unknown };
export type SpantypesFlamegraphSpanDTOResource = { [key: string]: string };
export interface SpantypesFlamegraphSpanDTO {
/**
* @type object
*/
attributes: SpantypesFlamegraphSpanDTOAttributes;
/**
* @type integer
* @minimum 0
*/
durationNano: number;
/**
* @type array
*/
event: SpantypesEventDTO[];
/**
* @type boolean
*/
hasError: boolean;
/**
* @type integer
* @format int64
*/
level: number;
/**
* @type string
*/
name: string;
/**
* @type string
*/
parentSpanId: string;
/**
* @type object
*/
resource: SpantypesFlamegraphSpanDTOResource;
/**
* @type string
*/
spanId: string;
/**
* @type integer
* @minimum 0
*/
timestamp: number;
}
export interface SpantypesGettableFlamegraphTraceDTO {
/**
* @type integer
* @format int64
*/
endTimestampMillis: number;
/**
* @type boolean
*/
hasMore: boolean;
/**
* @type array
*/
spans: SpantypesFlamegraphSpanDTO[][];
/**
* @type integer
* @format int64
*/
startTimestampMillis: number;
}
export type SpantypesSpanMapperGroupConditionDTOAnyOf = {
/**
* @type array,null
@@ -8075,10 +8093,6 @@ export interface SpantypesWaterfallSpanDTO {
}
export interface SpantypesGettableWaterfallTraceDTO {
/**
* @type array,null
*/
aggregations?: SpantypesSpanAggregationResultDTO[] | null;
/**
* @type integer
* @minimum 0
@@ -8125,6 +8139,17 @@ export interface SpantypesGettableWaterfallTraceDTO {
uncollapsedSpans?: string[] | null;
}
export interface SpantypesPostableFlamegraphDTO {
/**
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* @type string
*/
selectedSpanId?: string;
}
export enum SpantypesSpanMapperOperationDTO {
move = 'move',
copy = 'copy',
@@ -8187,15 +8212,6 @@ export interface SpantypesPostableTraceAggregationsDTO {
}
export interface SpantypesPostableWaterfallDTO {
/**
* @type array,null
*/
aggregations?: SpantypesSpanAggregationDTO[] | null;
/**
* @type integer
* @minimum 0
*/
limit?: number;
/**
* @type string
*/
@@ -9631,42 +9647,6 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListDashboardsV2Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @type string
* @description undefined
*/
sort?: string;
/**
* @type string
* @description undefined
*/
order?: string;
/**
* @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;
/**
@@ -10515,11 +10495,11 @@ export type GetHosts200 = {
status: string;
};
export type GetWaterfallPathParameters = {
export type GetFlamegraphPathParameters = {
traceID: string;
};
export type GetWaterfall200 = {
data: SpantypesGettableWaterfallTraceDTO;
export type GetFlamegraph200 = {
data: SpantypesGettableFlamegraphTraceDTO;
/**
* @type string
*/

View File

@@ -12,13 +12,14 @@ import type {
} from 'react-query';
import type {
GetFlamegraph200,
GetFlamegraphPathParameters,
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetWaterfall200,
GetWaterfallPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
SpantypesPostableFlamegraphDTO,
SpantypesPostableTraceAggregationsDTO,
SpantypesPostableWaterfallDTO,
} from '../sigNoz.schemas';
@@ -127,46 +128,46 @@ export const useGetTraceAggregations = <
return useMutation(getGetTraceAggregationsMutationOptions(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
* Returns the flamegraph view of spans for a given trace ID.
* @summary Get flamegraph view for a trace
*/
export const getWaterfall = (
{ traceID }: GetWaterfallPathParameters,
spantypesPostableWaterfallDTO?: BodyType<SpantypesPostableWaterfallDTO>,
export const getFlamegraph = (
{ traceID }: GetFlamegraphPathParameters,
spantypesPostableFlamegraphDTO?: BodyType<SpantypesPostableFlamegraphDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetWaterfall200>({
url: `/api/v3/traces/${traceID}/waterfall`,
return GeneratedAPIInstance<GetFlamegraph200>({
url: `/api/v3/traces/${traceID}/flamegraph`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableWaterfallDTO,
data: spantypesPostableFlamegraphDTO,
signal,
});
};
export const getGetWaterfallMutationOptions = <
export const getGetFlamegraphMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
Awaited<ReturnType<typeof getFlamegraph>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
Awaited<ReturnType<typeof getFlamegraph>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
},
TContext
> => {
const mutationKey = ['getWaterfall'];
const mutationKey = ['getFlamegraph'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
@@ -176,54 +177,54 @@ export const getGetWaterfallMutationOptions = <
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof getWaterfall>>,
Awaited<ReturnType<typeof getFlamegraph>>,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return getWaterfall(pathParams, data);
return getFlamegraph(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type GetWaterfallMutationResult = NonNullable<
Awaited<ReturnType<typeof getWaterfall>>
export type GetFlamegraphMutationResult = NonNullable<
Awaited<ReturnType<typeof getFlamegraph>>
>;
export type GetWaterfallMutationBody =
| BodyType<SpantypesPostableWaterfallDTO>
export type GetFlamegraphMutationBody =
| BodyType<SpantypesPostableFlamegraphDTO>
| undefined;
export type GetWaterfallMutationError = ErrorType<RenderErrorResponseDTO>;
export type GetFlamegraphMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get waterfall view for a trace
* @summary Get flamegraph view for a trace
*/
export const useGetWaterfall = <
export const useGetFlamegraph = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
Awaited<ReturnType<typeof getFlamegraph>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getWaterfall>>,
Awaited<ReturnType<typeof getFlamegraph>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
pathParams: GetFlamegraphPathParameters;
data?: BodyType<SpantypesPostableFlamegraphDTO>;
},
TContext
> => {
return useMutation(getGetWaterfallMutationOptions(options));
return useMutation(getGetFlamegraphMutationOptions(options));
};
/**
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3

View File

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

View File

@@ -36,7 +36,7 @@
align-items: center;
justify-content: center;
}
.pieTooltipContent {
.pieChartTooltipContent {
display: flex;
align-items: center;
justify-content: center;
@@ -51,7 +51,7 @@
display: inline-block;
}
.tooltipValue {
.pieChartTooltipValue {
font-weight: bold;
margin-top: 4px;
}
@@ -59,7 +59,7 @@
// Wraps the shared chart Legend. Its width/height are set inline from the
// computed chart dimensions, so the VirtuosoGrid inside gets the same bounded
// box (right column / bottom rows) the uPlot charts use.
.pieLegend {
.pieChartLegend {
flex: 0 0 auto;
min-height: 0;
min-width: 0;

View File

@@ -11,18 +11,13 @@ import { LegendPosition } from 'lib/uPlotV2/components/types';
import { PieChartProps, PieSlice } from '../types';
import { calculateChartDimensions } from '../utils';
import { usePieInteractions } from '../../hooks/usePieInteractions';
import PieArc from './PieArc';
import PieCenterLabel from './PieCenterLabel';
import styles from './Pie.module.scss';
import { usePieInteractions } from './usePieInteractions';
import { PieTooltipData } from './types';
import { getFillColor } from './utils';
interface PieTooltipData {
label: string;
value: string;
color: string;
}
/**
* Donut chart rendered with @visx. Splits its area into chart + legend with the
* same `calculateChartDimensions` logic as the uPlot charts (right column /
@@ -71,21 +66,34 @@ export default function Pie({
// Reuse the uPlot chart/legend split so the donut + legend get the same area
// allocation (right column, or up-to-two bottom rows) as every other panel.
const dimensions = useMemo(
() =>
calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig: { position },
seriesLabels: data.map((slice) => slice.label),
}),
[containerWidth, containerHeight, position, data],
const { width, height, legendWidth, legendHeight, averageLegendWidth } =
useMemo(
() =>
calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig: { position },
seriesLabels: data.map((slice) => slice.label),
}),
[containerWidth, containerHeight, position, data],
);
// Donut geometry derived from the allocated chart box.
const { size, radius, innerRadius } = useMemo(() => {
const nextSize = Math.min(width, height);
const nextRadius = nextSize * 0.35;
return {
size: nextSize,
radius: nextRadius,
innerRadius: nextRadius * 0.6,
};
}, [width, height]);
const totalValue = useMemo(
() => visibleData.reduce((sum, slice) => sum + slice.value, 0),
[visibleData],
);
const size = Math.min(dimensions.width, dimensions.height);
const radius = size * 0.35;
const innerRadius = radius * 0.6;
const totalValue = visibleData.reduce((sum, slice) => sum + slice.value, 0);
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
const activeColor = active?.color ?? null;
@@ -101,19 +109,12 @@ export default function Pie({
),
color: slice.color,
},
tooltipTop: centroidY + dimensions.height / 2,
tooltipLeft: centroidX + dimensions.width / 2,
tooltipTop: centroidY + height / 2,
tooltipLeft: centroidX + width / 2,
});
setActive(slice);
},
[
showTooltip,
setActive,
yAxisUnit,
decimalPrecision,
dimensions.height,
dimensions.width,
],
[showTooltip, setActive, yAxisUnit, decimalPrecision, height, width],
);
const handleSliceLeave = useCallback((): void => {
@@ -142,17 +143,10 @@ export default function Pie({
style={{ flexDirection: isRightLegend ? 'row' : 'column' }}
data-testid={testId}
>
<div
className={styles.pieChartContainer}
style={{ width: dimensions.width, height: dimensions.height }}
>
<div className={styles.pieChartContainer} style={{ width, height }}>
{size > 0 && (
<svg
width={dimensions.width}
height={dimensions.height}
ref={containerRef}
>
<Group top={dimensions.height / 2} left={dimensions.width / 2}>
<svg width={width} height={height} ref={containerRef}>
<Group top={height / 2} left={width / 2}>
<VisxPie
data={visibleData}
pieValue={(slice: PieSlice): number => slice.value}
@@ -212,24 +206,24 @@ export default function Pie({
className={styles.pieChartIndicator}
style={{ background: tooltipData.color }}
/>
<div className={styles.pieTooltipContent}>
<div className={styles.pieChartTooltipContent}>
<span>{tooltipData.label}</span>
<span className={styles.tooltipValue}>{tooltipData.value}</span>
<span className={styles.pieChartTooltipValue}>{tooltipData.value}</span>
</div>
</TooltipInPortal>
)}
</div>
<div
className={styles.pieLegend}
className={styles.pieChartLegend}
style={{
width: dimensions.legendWidth,
height: dimensions.legendHeight,
width: legendWidth,
height: legendHeight,
}}
>
<Legend
items={legendItems}
position={position}
averageLegendWidth={dimensions.averageLegendWidth}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { PieSlice } from '../../types';
import Pie from '../Pie';
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: jest.fn().mockReturnValue({ width: 400, height: 300 }),
}));
jest.mock('components/Graph/yAxisConfig', () => ({
getYAxisFormattedValue: jest.fn((value: string) => value),
}));
// VirtuosoGrid only renders a window in jsdom; render every item so we can
// assert on legend entries.
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
}): JSX.Element => (
<div data-testid="virtuoso-grid">
{data.map((item, index) => (
<div key={item.seriesIndex ?? index}>{itemContent(index, item)}</div>
))}
</div>
),
}));
const DATA: PieSlice[] = [
{ label: 'frontend', value: 100, color: '#aa0000' },
{ label: 'cart', value: 60, color: '#00aa00' },
{ label: 'checkout', value: 40, color: '#0000aa' },
];
function renderPie(
props: Partial<React.ComponentProps<typeof Pie>> = {},
): void {
render(
<TooltipProvider>
<Pie data={DATA} isDarkMode={false} data-testid="pie" {...props} />
</TooltipProvider>,
);
}
describe('Pie', () => {
it('renders the "No data" state for empty data', () => {
render(
<TooltipProvider>
<Pie data={[]} isDarkMode={false} data-testid="pie" />
</TooltipProvider>,
);
expect(screen.getByText('No data')).toBeInTheDocument();
});
it('renders one arc per slice plus the legend entries and centre total', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
expect(svg.querySelectorAll('path')).toHaveLength(DATA.length);
const legend = screen.getByTestId('virtuoso-grid');
expect(within(legend).getByText('frontend')).toBeInTheDocument();
expect(within(legend).getByText('cart')).toBeInTheDocument();
expect(within(legend).getByText('checkout')).toBeInTheDocument();
// Centre total = 100 + 60 + 40 (formatter mocked to echo the value).
expect(screen.getByText('200')).toBeInTheDocument();
});
it('lays the legend out in a row for the right position and a column for bottom', () => {
const { rerender } = render(
<TooltipProvider>
<Pie
data={DATA}
isDarkMode={false}
position={LegendPosition.RIGHT}
data-testid="pie"
/>
</TooltipProvider>,
);
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'row' });
rerender(
<TooltipProvider>
<Pie
data={DATA}
isDarkMode={false}
position={LegendPosition.BOTTOM}
data-testid="pie"
/>
</TooltipProvider>,
);
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
});
it('hides a slice when its legend marker is clicked', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
expect(svg.querySelectorAll('path')).toHaveLength(3);
const marker = document.querySelector(
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
) as HTMLElement;
fireEvent.click(marker);
// One slice hidden → one fewer arc drawn.
expect(svg.querySelectorAll('path')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,85 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { PieSlice } from '../../types';
import PieArc from '../PieArc';
jest.mock('components/Graph/yAxisConfig', () => ({
// Echo the raw value so assertions are deterministic.
getYAxisFormattedValue: jest.fn((value: string) => value),
}));
const SLICE: PieSlice = { label: 'frontend', value: 50, color: '#f00' };
function renderArc(props: Partial<React.ComponentProps<typeof PieArc>> = {}): {
onEnter: jest.Mock;
onLeave: jest.Mock;
onClick: jest.Mock;
container: HTMLElement;
} {
const onEnter = jest.fn();
const onLeave = jest.fn();
const onClick = jest.fn();
const { container } = render(
<svg>
<PieArc
slice={SLICE}
arcPath="M0,0L1,1"
centroid={[10, 20]}
startAngle={0}
endAngle={Math.PI}
radius={100}
totalValue={100}
labelColor="#fff"
fill="#f00"
onEnter={onEnter}
onLeave={onLeave}
onClick={onClick}
{...props}
/>
</svg>,
);
return { onEnter, onLeave, onClick, container };
}
describe('PieArc', () => {
it('renders the arc path with the resolved fill', () => {
const { container } = renderArc();
const path = container.querySelector('path');
expect(path).toHaveAttribute('d', 'M0,0L1,1');
expect(path).toHaveAttribute('fill', '#f00');
});
it('shows the leader label + value for a slice above the threshold', () => {
renderArc(); // 50 / 100 = 0.5
expect(screen.getByText('frontend')).toBeInTheDocument();
expect(screen.getByText('50')).toBeInTheDocument();
});
it('hides the leader label for a slice below the 3% threshold', () => {
renderArc({ totalValue: 10000 }); // 50 / 10000 = 0.005
expect(screen.queryByText('frontend')).not.toBeInTheDocument();
// the arc path itself still renders
expect(screen.queryByText('50')).not.toBeInTheDocument();
});
it('truncates labels longer than 15 chars', () => {
renderArc({
slice: { label: 'a-really-long-service-name', value: 50, color: '#f00' },
});
expect(screen.getByText('a-really-lon...')).toBeInTheDocument();
});
it('fires onEnter with the slice + centroid, and onLeave / onClick', () => {
const { onEnter, onLeave, onClick, container } = renderArc();
const g = container.querySelector('g') as SVGGElement;
fireEvent.mouseEnter(g);
expect(onEnter).toHaveBeenCalledWith(SLICE, 10, 20);
fireEvent.mouseLeave(g);
expect(onLeave).toHaveBeenCalledTimes(1);
fireEvent.click(g);
expect(onClick).toHaveBeenCalledWith(SLICE);
});
});

View File

@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import PieCenterLabel from '../PieCenterLabel';
jest.mock('components/Graph/yAxisConfig', () => ({
getYAxisFormattedValue: jest.fn(),
}));
const mockFormat = getYAxisFormattedValue as jest.MockedFunction<
typeof getYAxisFormattedValue
>;
function renderInSvg(node: JSX.Element): ReturnType<typeof render> {
// PieCenterLabel returns an SVG <text>, so it needs an <svg> host.
return render(<svg>{node}</svg>);
}
describe('PieCenterLabel', () => {
const baseProps = {
total: 3700,
radius: 100,
innerRadius: 60,
color: '#fff',
};
it('renders the formatted total (numeric + unit suffix) as one numeric tspan when there is no separate unit', () => {
mockFormat.mockReturnValue('3.7K');
renderInSvg(<PieCenterLabel {...baseProps} />);
expect(screen.getByText('3.7K')).toBeInTheDocument();
});
it('splits the numeric part and the trailing unit into separate tspans', () => {
mockFormat.mockReturnValue('1.2 MB');
renderInSvg(<PieCenterLabel {...baseProps} />);
expect(screen.getByText('1.2')).toBeInTheDocument();
expect(screen.getByText('MB')).toBeInTheDocument();
});
it('passes the unit + precision through to the formatter', () => {
mockFormat.mockReturnValue('100');
renderInSvg(<PieCenterLabel {...baseProps} total={100} yAxisUnit="bytes" />);
expect(mockFormat).toHaveBeenCalledWith('100', 'bytes', undefined);
});
});

View File

@@ -0,0 +1,101 @@
import {
getArcGeometry,
getFillColor,
getScaledFontSize,
lightenColor,
} from '../utils';
describe('Pie utils', () => {
describe('getScaledFontSize', () => {
it('returns the base size for empty text', () => {
expect(getScaledFontSize({ text: '', baseSize: 30, innerRadius: 100 })).toBe(
30,
);
});
it('does not scale short text (length <= 3)', () => {
// scaleFactor = max(0.3, 1) = 1 → baseSize, capped by innerRadius * 0.9.
expect(
getScaledFontSize({ text: '3.7', baseSize: 30, innerRadius: 100 }),
).toBe(30);
});
it('scales longer text down', () => {
// length 8 → scaleFactor = max(0.3, 1 - 5 * 0.09) = 0.55 → 30 * 0.55.
expect(
getScaledFontSize({ text: '12345678', baseSize: 30, innerRadius: 100 }),
).toBeCloseTo(16.5);
});
it('floors the scale factor at 0.3 for very long text', () => {
// length 20 → 1 - 17 * 0.09 < 0.3 → floored to 0.3 → 100 * 0.3.
expect(
getScaledFontSize({
text: '12345678901234567890',
baseSize: 100,
innerRadius: 1000,
}),
).toBeCloseTo(30);
});
it('caps the size at 90% of the inner radius', () => {
expect(
getScaledFontSize({ text: '3.7', baseSize: 200, innerRadius: 10 }),
).toBeCloseTo(9);
});
});
describe('getArcGeometry', () => {
it('places the label below for a slice centred at the top (angle 0)', () => {
const g = getArcGeometry(0, 0, 100);
expect(g.labelX).toBeCloseTo(0);
expect(g.labelY).toBeCloseTo(-130);
expect(g.lineEndX).toBeCloseTo(0);
expect(g.lineEndY).toBeCloseTo(-110);
// sin(0) is not > 0 → anchor end.
expect(g.textAnchor).toBe('end');
});
it('anchors to the start on the right half (angle pi/2)', () => {
const g = getArcGeometry(0, Math.PI, 100);
expect(g.labelX).toBeCloseTo(130);
expect(g.labelY).toBeCloseTo(0);
expect(g.textAnchor).toBe('start');
});
it('anchors to the end on the left half (angle 3pi/2)', () => {
const g = getArcGeometry(Math.PI, 2 * Math.PI, 100);
expect(g.labelX).toBeCloseTo(-130);
expect(g.textAnchor).toBe('end');
});
});
describe('lightenColor', () => {
it('converts a #rrggbb hex to rgba at the given opacity', () => {
expect(lightenColor('#ff0000', 0.4)).toBe('rgba(255, 0, 0, 0.4)');
});
it('accepts hex without a leading #', () => {
expect(lightenColor('00ff00', 0.4)).toBe('rgba(0, 255, 0, 0.4)');
});
it('returns the original colour when it is not parseable hex', () => {
expect(lightenColor('rgba(0,0,0,1)', 0.4)).toBe('rgba(0,0,0,1)');
expect(lightenColor('red', 0.4)).toBe('red');
});
});
describe('getFillColor', () => {
it('returns the colour unchanged when nothing is active', () => {
expect(getFillColor('#ff0000', null)).toBe('#ff0000');
});
it('returns the colour unchanged for the active slice', () => {
expect(getFillColor('#ff0000', '#ff0000')).toBe('#ff0000');
});
it('dims non-active slices to 40% opacity', () => {
expect(getFillColor('#00ff00', '#ff0000')).toBe('rgba(0, 255, 0, 0.4)');
});
});
});

View File

@@ -0,0 +1,35 @@
/**
* Pie-local types. Kept out of the component / util files so each stays focused
* (per the one-component-per-file + dedicated-types rules). Shared chart types
* (PieSlice, PieChartProps) live in the parent charts/types.ts.
*/
export interface ScaledFontSizeArgs {
text: string;
baseSize: number;
innerRadius: number;
}
export interface ArcGeometry {
/** Outer point where the leader label sits. */
labelX: number;
labelY: number;
/** Elbow point where the leader line bends toward the label. */
lineEndX: number;
lineEndY: number;
/** Anchor the label left/right depending on which half of the circle it's in. */
textAnchor: 'start' | 'end';
}
export interface ParsedRgb {
r: number;
g: number;
b: number;
}
/** Resolved tooltip payload shown when a slice is hovered. */
export interface PieTooltipData {
label: string;
value: string;
color: string;
}

View File

@@ -3,11 +3,7 @@
* so the renderer stays declarative (per the one-component-per-file rule).
*/
interface ScaledFontSizeArgs {
text: string;
baseSize: number;
innerRadius: number;
}
import { ArcGeometry, ParsedRgb, ScaledFontSizeArgs } from './types';
/**
* Shrinks the centre-total font as the text gets longer so it never overflows
@@ -31,17 +27,6 @@ export function getScaledFontSize({
return Math.min(baseSize * scaleFactor, maxSize);
}
export interface ArcGeometry {
/** Outer point where the leader label sits. */
labelX: number;
labelY: number;
/** Elbow point where the leader line bends toward the label. */
lineEndX: number;
lineEndY: number;
/** Anchor the label left/right depending on which half of the circle it's in. */
textAnchor: 'start' | 'end';
}
/**
* Computes the leader-line / label geometry for one arc from its angular span.
* Pulled out of the render prop so the SVG markup stays declarative.
@@ -63,12 +48,6 @@ export function getArcGeometry(
};
}
interface ParsedRgb {
r: number;
g: number;
b: number;
}
// Parses `#rrggbb` into its components. Returns null for anything else (e.g. an
// already-rgba string), letting callers fall back to the original colour.
function hexToRgb(color: string): ParsedRgb | null {

View File

@@ -0,0 +1,147 @@
import { act, renderHook } from '@testing-library/react';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
import type { MouseEvent } from 'react';
import { PieSlice } from '../../charts/types';
import { usePieInteractions } from '../usePieInteractions';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
);
const mockGetStored = getStoredSeriesVisibility as jest.MockedFunction<
typeof getStoredSeriesVisibility
>;
const mockUpdateStored =
updateSeriesVisibilityToLocalStorage as jest.MockedFunction<
typeof updateSeriesVisibilityToLocalStorage
>;
const DATA: PieSlice[] = [
{ label: 'frontend', value: 100, color: '#a' },
{ label: 'cart', value: 60, color: '#b' },
{ label: 'checkout', value: 40, color: '#c' },
];
// Builds a fake legend click/move event: `e.target.closest('[data-legend-item-id]')`
// resolves to the item at `index`, and `e.target.dataset.isLegendMarker` flags marker clicks.
function legendEvent(
index: number | null,
isMarker = false,
): MouseEvent<HTMLDivElement> {
const itemEl =
index == null ? null : { dataset: { legendItemId: String(index) } };
return {
target: {
closest: (): unknown => itemEl,
dataset: { isLegendMarker: isMarker ? 'true' : undefined },
},
} as unknown as MouseEvent<HTMLDivElement>;
}
describe('usePieInteractions', () => {
beforeEach(() => {
mockGetStored.mockReturnValue(null);
mockUpdateStored.mockReset();
});
it('starts with everything visible and nothing focused', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
true,
true,
true,
]);
expect(result.current.focusedSeriesIndex).toBeNull();
expect(result.current.active).toBeNull();
});
describe('marker click (toggle one)', () => {
it('hides then unhides the clicked slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
act(() => result.current.onLegendClick(legendEvent(1, true)));
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
expect(mockUpdateStored).toHaveBeenLastCalledWith('panel-1', [
{ label: 'frontend', show: true },
{ label: 'cart', show: false },
{ label: 'checkout', show: true },
]);
act(() => result.current.onLegendClick(legendEvent(1, true)));
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems[1].show).toBe(true);
});
});
describe('label click (isolate / reset)', () => {
it('isolates the clicked slice, then resets on a second click', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(0, false)));
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
true,
false,
false,
]);
act(() => result.current.onLegendClick(legendEvent(0, false)));
expect(result.current.visibleData).toStrictEqual(DATA);
});
});
describe('hover', () => {
it('focuses the hovered slice and clears on leave', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendMouseMove(legendEvent(2)));
expect(result.current.active).toStrictEqual(DATA[2]);
expect(result.current.focusedSeriesIndex).toBe(2);
act(() => result.current.onLegendMouseLeave());
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('does not focus a hidden slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
act(() => result.current.onLegendMouseMove(legendEvent(1)));
expect(result.current.active).toBeNull();
});
});
describe('persistence', () => {
it('does not write to storage when no id is provided', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(0, true)));
expect(mockUpdateStored).not.toHaveBeenCalled();
});
it('rehydrates hidden slices from storage on mount (matched by label)', () => {
mockGetStored.mockReturnValue([
{ label: 'frontend', show: true },
{ label: 'cart', show: false },
{ label: 'checkout', show: true },
]);
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
});
});
});

View File

@@ -5,8 +5,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from '../../panels/utils/legendVisibilityUtils';
import { PieSlice } from '../types';
} from '../panels/utils/legendVisibilityUtils';
import { PieSlice } from '../charts/types';
export interface UsePieInteractionsResult {
/** The hovered/focused slice (drives donut dimming + tooltip). */

View File

@@ -72,7 +72,7 @@ export const deploymentWidgetInfo = [
yAxisUnit: '',
},
{
title: 'Memory usage, request, limits)',
title: 'Memory usage, request, limits',
yAxisUnit: 'bytes',
},
{

View File

@@ -69,7 +69,7 @@ export const jobWidgetInfo = [
yAxisUnit: '',
},
{
title: 'Memory usage, request, limits',
title: 'Memory Usage',
yAxisUnit: 'bytes',
},
{

View File

@@ -703,7 +703,7 @@ export const getNamespaceMetricsQueryPayload = (
],
having: [],
legend: `{{${k8sPodNameKey}}}`,
limit: 20,
limit: 10,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
@@ -1014,8 +1014,8 @@ export const getNamespaceMetricsQueryPayload = (
id: '5f2a55c5',
key: {
dataType: DataTypes.String,
id: k8sStatefulsetNameKey,
key: k8sStatefulsetNameKey,
id: k8sNamespaceNameKey,
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',

View File

@@ -317,9 +317,9 @@ export const getVolumeMetricsQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_volume_inodes_used--float64----true',
id: 'k8s_volume_inodes_used--float64--Gauge--true',
key: k8sVolumeInodesUsedKey,
type: '',
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
@@ -409,9 +409,9 @@ export const getVolumeMetricsQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_volume_inodes--float64----true',
id: 'k8s_volume_inodes--float64--Gauge--true',
key: k8sVolumeInodesKey,
type: '',
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
@@ -501,9 +501,9 @@ export const getVolumeMetricsQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_volume_inodes_free--float64----true',
id: 'k8s_volume_inodes_free--float64--Gauge--true',
key: k8sVolumeInodesFreeKey,
type: '',
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,

View File

@@ -1619,6 +1619,9 @@ export const getHostQueryPayload = (
const diskOpTimeKey = dotMetricsEnabled
? 'system.disk.operation_time'
: 'system_disk_operation_time';
const diskOpsKey = dotMetricsEnabled
? 'system.disk.operations'
: 'system_disk_operations';
const diskPendingKey = dotMetricsEnabled
? 'system.disk.pending_operations'
: 'system_disk_pending_operations';
@@ -2375,9 +2378,24 @@ export const getHostQueryPayload = (
op: 'AND',
},
functions: [],
groupBy: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'device--string--tag--false',
key: 'device',
type: 'tag',
},
],
having: [],
legend: 'system disk io',
legend: '{{device}}::{{direction}}',
limit: null,
orderBy: [],
queryName: 'A',
@@ -2409,9 +2427,9 @@ export const getHostQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_disk_operation_time--float64--Sum--true',
id: 'system_disk_operations--float64--Sum--true',
key: diskOpTimeKey,
key: diskOpsKey,
type: 'Sum',
},
aggregateOperator: 'rate',
@@ -2421,7 +2439,7 @@ export const getHostQueryPayload = (
filters: {
items: [
{
id: 'diskop_f1',
id: 'diskops_f1',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
@@ -2454,7 +2472,7 @@ export const getHostQueryPayload = (
],
having: [
{
columnName: `SUM(${diskOpTimeKey})`,
columnName: `SUM(${diskOpsKey})`,
op: '>',
value: 0,
},
@@ -2557,6 +2575,88 @@ export const getHostQueryPayload = (
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_disk_operation_time--float64--Sum--true',
key: diskOpTimeKey,
type: 'Sum',
},
aggregateOperator: 'rate',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: 'diskoptime_f1',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
key: hostNameKey,
type: 'tag',
},
op: '=',
value: hostName,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'device--string--tag--false',
key: 'device',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
],
having: [
{
columnName: `SUM(${diskOpTimeKey})`,
op: '>',
value: 0,
},
],
legend: '{{device}}::{{direction}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'rate',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
id: 'a8b3d2e1-4f5c-4a6b-9c8d-7e2f1a0b3c4f',
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
];
};
@@ -2631,5 +2731,5 @@ export const hostWidgetInfo = [
{ title: 'System disk io (bytes transferred)', yAxisUnit: 'bytes' },
{ title: 'System disk operations/s', yAxisUnit: 'short' },
{ title: 'Queue size', yAxisUnit: 'short' },
{ title: 'Disk operations time', yAxisUnit: 's' },
{ title: 'System disk operation time/s', yAxisUnit: 's' },
];

View File

@@ -96,14 +96,28 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return undefined;
}
const { domainToAdminEmailList, ...rest } = config;
const {
domainToAdminEmailList,
allowedGroups,
serviceAccountJson,
domainToAdminEmail: _domainToAdminEmail,
fetchTransitiveGroupMembership,
...rest
} = config;
const domainToAdminEmail = convertDomainMappingsToRecord(
domainToAdminEmailList,
);
return {
...rest,
domainToAdminEmail: domainToAdminEmail ?? {},
...(rest.fetchGroups
? {
allowedGroups,
serviceAccountJson,
domainToAdminEmail: domainToAdminEmail ?? {},
fetchTransitiveGroupMembership,
}
: { domainToAdminEmail: {} }),
};
}, [form]);
@@ -129,7 +143,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return {
...rest,
groupMappings: groupMappings ?? {},
groupMappings: rest.useRoleAttribute ? undefined : (groupMappings ?? {}),
};
}, [form]);

View File

@@ -0,0 +1,195 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockDomainWithRoleMapping,
mockGoogleAuthDomain,
mockGoogleAuthWithWorkspaceGroups,
mockOidcWithClaimMapping,
mockSamlWithAttributeMapping,
mockUpdateSuccessResponse,
} from './mocks';
// @signozhq/ui/button internal effects block form.validateFields() in tests
jest.mock('@signozhq/ui/button', () => ({
...jest.requireActual('@signozhq/ui/button'),
Button: ({
children,
onClick,
loading,
disabled,
'aria-label': ariaLabel,
prefix,
suffix,
}: {
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
loading?: boolean;
disabled?: boolean;
'aria-label'?: string;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
aria-label={ariaLabel}
>
{prefix}
{children}
{suffix}
</button>
),
}));
type SavedPayload = {
config: {
googleAuthConfig?: Record<string, unknown>;
samlConfig?: Record<string, unknown>;
oidcConfig?: Record<string, unknown>;
roleMapping?: Record<string, unknown>;
};
};
async function submitForm(
record: AuthtypesGettableAuthDomainDTO,
): Promise<SavedPayload> {
const requests: SavedPayload[] = [];
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
requests.push((await req.json()) as SavedPayload);
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(<CreateEdit isCreate={false} record={record} onClose={jest.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() => expect(requests).toHaveLength(1));
return requests[0];
}
describe('CreateEdit — payload sanitization', () => {
afterEach(() => server.resetHandlers());
describe('Google Auth', () => {
it('sends core fields and omits workspace fields when fetchGroups is not set', async () => {
const payload = await submitForm(mockGoogleAuthDomain);
const g = payload.config.googleAuthConfig;
expect(g?.clientId).toBe('test-client-id');
expect(g?.clientSecret).toBe('test-client-secret');
expect(g?.allowedGroups).toBeUndefined();
expect(g?.serviceAccountJson).toBeUndefined();
expect(g?.fetchTransitiveGroupMembership).toBeUndefined();
expect(g?.domainToAdminEmail).toStrictEqual({});
});
it('strips workspace fields when fetchGroups is false', async () => {
const payload = await submitForm({
...mockGoogleAuthWithWorkspaceGroups,
config: {
...mockGoogleAuthWithWorkspaceGroups.config,
googleAuthConfig: {
...mockGoogleAuthWithWorkspaceGroups.config?.googleAuthConfig,
fetchGroups: false,
},
},
});
const g = payload.config.googleAuthConfig;
expect(g?.fetchGroups).toBe(false);
expect(g?.allowedGroups).toBeUndefined();
expect(g?.serviceAccountJson).toBeUndefined();
expect(g?.fetchTransitiveGroupMembership).toBeUndefined();
expect(g?.domainToAdminEmail).toStrictEqual({});
});
it('includes all workspace fields when fetchGroups is true', async () => {
const payload = await submitForm(mockGoogleAuthWithWorkspaceGroups);
const g = payload.config.googleAuthConfig;
expect(g?.fetchGroups).toBe(true);
expect(g?.serviceAccountJson).toBe('{"type": "service_account"}');
expect(g?.fetchTransitiveGroupMembership).toBe(true);
expect(g?.allowedGroups).toStrictEqual([
'allowed-group-1',
'allowed-group-2',
]);
expect(g?.domainToAdminEmail).toStrictEqual({
'google-groups.com': 'admin@google-groups.com',
});
});
});
describe('SAML', () => {
it('sends core and attributeMapping fields', async () => {
const payload = await submitForm(mockSamlWithAttributeMapping);
const s = payload.config.samlConfig;
expect(s?.samlIdp).toBe('https://idp.saml-attrs.com/sso');
expect(s?.samlEntity).toBe('urn:saml-attrs:idp');
expect(s?.samlCert).toBe('MOCK_CERTIFICATE_ATTRS');
expect(s?.insecureSkipAuthNRequestsSigned).toBe(true);
const attr = s?.attributeMapping as Record<string, unknown>;
expect(attr?.name).toBe('user_display_name');
expect(attr?.groups).toBe('member_of');
expect(attr?.role).toBe('signoz_role');
});
});
describe('OIDC', () => {
it('sends all fields including claimMapping', async () => {
const payload = await submitForm(mockOidcWithClaimMapping);
const o = payload.config.oidcConfig;
expect(o?.issuer).toBe('https://oidc.claims.com');
expect(o?.issuerAlias).toBe('https://alias.claims.com');
expect(o?.clientId).toBe('claims-client-id');
expect(o?.clientSecret).toBe('claims-client-secret');
expect(o?.insecureSkipEmailVerified).toBe(true);
expect(o?.getUserInfo).toBe(true);
const claim = o?.claimMapping as Record<string, unknown>;
expect(claim?.email).toBe('user_email');
expect(claim?.name).toBe('display_name');
expect(claim?.groups).toBe('user_groups');
expect(claim?.role).toBe('user_role');
});
});
describe('Role Mapping', () => {
it('strips groupMappings when useRoleAttribute is true', async () => {
const payload = await submitForm({
...mockDomainWithRoleMapping,
config: {
...mockDomainWithRoleMapping.config,
roleMapping: {
...mockDomainWithRoleMapping.config?.roleMapping,
useRoleAttribute: true,
},
},
});
expect(payload.config.roleMapping?.useRoleAttribute).toBe(true);
expect(payload.config.roleMapping?.groupMappings).toBeUndefined();
});
it('sends groupMappings when useRoleAttribute is false', async () => {
const payload = await submitForm(mockDomainWithRoleMapping);
expect(payload.config.roleMapping?.useRoleAttribute).toBe(false);
expect(payload.config.roleMapping?.groupMappings).toStrictEqual({
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
});
});
});
});

View File

@@ -22,11 +22,12 @@ export const StyledCheckOutlined = styled(Check)`
float: right;
`;
export const TagContainer = styled(Badge)`
export const TagContainer = styled(Badge).attrs({
color: 'secondary',
variant: 'outline',
})`
&&& {
display: flex;
border-radius: 3px;
padding: 0.1rem 0.2rem;
font-weight: 300;
font-size: 0.6rem;
}
@@ -38,4 +39,5 @@ export const TagLabel = styled.span`
export const TagValue = styled.span`
text-transform: capitalize;
font-weight: 400;
`;

View File

@@ -173,17 +173,15 @@
.legend-copy-button {
// Always laid out (space reserved) but transparent, so revealing it on
// hover fades the icon in without reflowing the row / shifting the label.
display: flex;
// Shrink the shared icon Button (defaults to a 2rem square) to the
// compact legend row via its size tokens.
--button-height: auto;
--button-width: auto;
--button-padding: 2px;
opacity: 0;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 2px;
margin: 0;
border: none;
background: transparent;
color: var(--l2-foreground);
cursor: pointer;
border-radius: 4px;
transition:
opacity 0.15s ease,

View File

@@ -1,6 +1,7 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input } from 'antd';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
@@ -21,20 +22,20 @@ export const MAX_LEGEND_WIDTH = 240;
*/
export default function Legend({
items,
position = LegendPosition.BOTTOM,
position,
averageLegendWidth = MAX_LEGEND_WIDTH,
focusedSeriesIndex = null,
focusedSeriesIndex,
onClick,
onMouseMove,
onMouseLeave,
showCopy = true,
showSearch,
}: LegendProps): JSX.Element {
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [legendSearchQuery, setLegendSearchQuery] = useState('');
const { copyToClipboard, id: copiedId } = useCopyToClipboard();
const searchEnabled = showSearch ?? position === LegendPosition.RIGHT;
// Search is intrinsic to the right-positioned legend.
const searchEnabled = position === LegendPosition.RIGHT;
const isSingleRow = useMemo(() => {
if (!legendContainerRef.current || position !== LegendPosition.BOTTOM) {
@@ -79,7 +80,7 @@ export default function Legend({
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<TooltipSimple title={item.label} arrow side="top">
<TooltipSimple title={item.label} arrow side="top" disableHoverableContent>
<div className="legend-item-label-trigger">
<div
className="legend-marker"
@@ -90,18 +91,30 @@ export default function Legend({
</div>
</TooltipSimple>
{showCopy && (
<TooltipSimple title={isCopied ? 'Copied' : 'Copy'} arrow side="top">
<button
<TooltipSimple
title={isCopied ? 'Copied' : 'Copy'}
arrow
side="top"
disableHoverableContent
>
<Button
type="button"
size="icon"
variant="ghost"
color="secondary"
className="legend-copy-button"
onClick={(e): void =>
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
}
aria-label={`Copy ${item.label}`}
// data-testid (not testId): TooltipSimple's trigger injects
// data-testid:undefined via Radix Slot, and Button spreads
// incoming props after its own testId — so set it as a prop
// that wins the Slot merge and survives the spread.
data-testid="legend-copy"
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
</button>
</Button>
</TooltipSimple>
)}
</div>

View File

@@ -111,27 +111,27 @@ export interface LegendConfig {
position: LegendPosition;
}
/**
* Presentational legend props. Source-agnostic: it renders whatever
* `items` it's given and delegates interaction to the container handlers, so
* it serves both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
* Presentational legend props. Source-agnostic: it renders whatever `items`
* it's given and delegates interaction to the container handlers, so it serves
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie). The search
* box is intrinsic to the RIGHT position (derived from `position`, not a flag).
*/
export interface LegendProps {
items: LegendItem[];
position?: LegendPosition;
/** Legend placement; always supplied by the container. */
position: LegendPosition;
averageLegendWidth?: number;
/** Series index to highlight (hovered/focused). */
focusedSeriesIndex?: number | null;
focusedSeriesIndex: number | null;
/**
* Container-delegated handlers. Items carry `data-legend-item-id`, so the
* handler reads the target's id rather than binding per item.
*/
onClick?: MouseEventHandler<HTMLDivElement>;
onMouseMove?: MouseEventHandler<HTMLDivElement>;
onMouseLeave?: () => void;
onClick: MouseEventHandler<HTMLDivElement>;
onMouseMove: MouseEventHandler<HTMLDivElement>;
onMouseLeave: () => void;
/** Show the per-item copy button. Default true. */
showCopy?: boolean;
/** Show the filter search box. Default: only for the RIGHT position. */
showSearch?: boolean;
}
/** Props for the uPlot legend controller, which derives items + interaction

View File

@@ -7,20 +7,10 @@ import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import DashboardContainer from 'container/DashboardContainer';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardPageV2 from 'pages/DashboardPageV2';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ErrorType } from 'types/common';
function DashboardPage(): JSX.Element {
const isV2 = useIsDashboardV2();
if (isV2) {
return <DashboardPageV2 />;
}
return <DashboardPageV1 />;
}
function DashboardPageV1(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const [onModal, Content] = Modal.useModal();

View File

@@ -1,152 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { useTimezone } from 'providers/Timezone';
import { useGroupByPerQuery } from '../hooks/useGroupByPerQuery';
import { useTimeScale } from '../hooks/useTimeScale';
import PanelStyles from '../styles/panel.module.scss';
import { PanelRendererProps } from '../types';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../utils/getBuilderQueries';
import { buildBarChartConfig } from './config';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function BarPanelRenderer({
panelId,
panel,
data,
onClick,
onDragSelect,
dashboardPreference,
panelMode,
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/BarChartPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesBarChartPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
const { minTimeScale, maxTimeScale } = useTimeScale(data);
const groupByPerQuery = useGroupByPerQuery(builderQueries);
const config = useMemo(
() =>
buildBarChartConfig({
panelId,
spec,
builderQueries,
apiResponse: data,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
panelId,
spec,
builderQueries,
data,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
dashboardPreference?.syncMode,
],
);
const chartData = useMemo(
() => (data?.payload ? prepareChartData(data.payload) : []),
[data?.payload],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(() => {
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
),
[panelId],
);
// The uPlot key prop is the only way to force a full teardown and re-mount
// of the chart. Including syncMode/syncFilterMode in the key ensures changes
// to these preferences trigger a fresh chart instance, preventing stale
// sync wiring from being inherited.
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="bar-panel-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={key}
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default BarPanelRenderer;

View File

@@ -1,140 +0,0 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabel } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import type { BuilderQuery } from 'types/api/v5/queryRange';
export interface BuildBarChartConfigArgs {
panelId: string;
spec: DashboardtypesBarChartPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Bar chart panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the Bar-specific concerns: optional stacking via uPlot bands, plus
* one bar series per result row.
*/
export function buildBarChartConfig({
panelId,
spec,
builderQueries,
apiResponse,
isDarkMode,
timezone,
panelMode,
onDragSelect,
onClick,
minTimeScale,
maxTimeScale,
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.BAR,
isDarkMode,
timezone,
panelMode,
isLogScale: spec.axes?.isLogScale,
softMin: spec.axes?.softMin ?? undefined,
softMax: spec.axes?.softMax ?? undefined,
formatting: spec.formatting,
thresholds: spec.thresholds,
apiResponse,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
});
addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesBarChartPanelSpecDTO;
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
}
/**
* Adds one bar series per result row, plus uPlot bands for stacking when
* `spec.visualization.stackedBarChart` is set. Each series receives its
* own per-query step interval so bar widths line up with the actual
* sampling cadence reported by the backend.
*/
function addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
}: AddSeriesArgs): void {
const result = apiResponse?.payload?.data?.result;
if (!result) {
return;
}
const stepIntervals =
apiResponse?.payload?.data?.newResult?.meta?.stepIntervals;
const colorMapping = spec.legend?.customColors ?? {};
if (spec.visualization?.stackedBarChart) {
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
// `+1` keeps the band targets aligned with the series we're about to add.
builder.setBands(getInitialStackedBands(result.length + 1));
}
result.forEach((series) => {
const baseLabel = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = resolveSeriesLabel(series, builderQueries, baseLabel);
const stepInterval = series.queryName
? stepIntervals?.[series.queryName]
: undefined;
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping,
isDarkMode,
stepInterval,
metric: series.metric,
});
});
}

View File

@@ -1,9 +0,0 @@
import type { SectionConfig } from '../types';
export const sections: SectionConfig[] = [
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { stacked: true } },
];

View File

@@ -1,126 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Histogram from 'container/DashboardContainer/visualization/charts/Histogram/Histogram';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import PanelStyles from '../styles/panel.module.scss';
import { PanelRendererProps } from '../types';
import { resolveLegendPosition } from '../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../utils/getBuilderQueries';
import { buildHistogramConfig } from './config';
import { prepareHistogramData } from './data';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function HistogramPanelRenderer({
panelId,
panel,
data,
panelMode,
onClick,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/HistogramPanel'`, so the cast is a
// documented boundary narrowing.
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
() =>
(panel?.spec?.plugin?.spec ?? {}) as DashboardtypesHistogramPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
const config = useMemo(
() =>
buildHistogramConfig({
panelId,
spec,
builderQueries,
apiResponse: data,
isDarkMode,
timezone,
panelMode,
}),
[panelId, spec, builderQueries, data, isDarkMode, timezone, panelMode],
);
const chartData = useMemo(
() =>
prepareHistogramData({
payload: data?.payload,
bucketWidth: spec.histogramBuckets?.bucketWidth ?? undefined,
bucketCount: spec.histogramBuckets?.bucketCount ?? undefined,
mergeAllActiveQueries: spec.histogramBuckets?.mergeAllActiveQueries,
}),
[
data?.payload,
spec.histogramBuckets?.bucketWidth,
spec.histogramBuckets?.bucketCount,
spec.histogramBuckets?.mergeAllActiveQueries,
],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter
id={panelId}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={false}
/>
),
[panelId],
);
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="histogram-panel-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<Histogram
key={panelId}
config={config}
data={chartData as uPlot.AlignedData}
legendConfig={{ position: legendPosition }}
canPinTooltip
isQueriesMerged={isQueriesMerged}
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default HistogramPanelRenderer;

View File

@@ -1,142 +0,0 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabel } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import getLabelName from 'lib/getLabelName';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import type { BuilderQuery } from 'types/api/v5/queryRange';
const POINT_SIZE = 5;
const BAR_WIDTH_FACTOR = 1;
// Merged-series colors mirror the V1 default — single histogram bin gets a
// fixed blue-ish pair so the merged view looks the same as before.
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
export interface BuildHistogramConfigArgs {
panelId: string;
spec: DashboardtypesHistogramPanelSpecDTO;
/** Builder queries on this panel — used to resolve per-series labels. */
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Histogram panel.
*
* Unlike time-axis panels, histograms have no time scale and no drag-to-zoom.
* We still reuse `buildBaseConfig` for the consistent scaffolding (thresholds,
* axes, click plugin) but then override the X/Y scales to be auto-linear
* (`time: false, auto: true`) and install a histogram-specific cursor that
* disables drag-pan and tightens focus proximity.
*/
export function buildHistogramConfig({
panelId,
spec,
builderQueries,
apiResponse,
isDarkMode,
timezone,
panelMode,
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.HISTOGRAM,
isDarkMode,
timezone,
panelMode,
apiResponse,
});
builder.setCursor({
drag: { x: false, y: false, setScale: true },
focus: { prox: 1e3 },
});
// Override the time-axis scales from `buildBaseConfig` — histograms are
// distribution plots, not time series.
builder.addScale({ scaleKey: 'x', time: false, auto: true });
builder.addScale({ scaleKey: 'y', time: false, auto: true, min: 0 });
addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesHistogramPanelSpecDTO;
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
}
/**
* Adds histogram bar series to the builder. When `mergeAllActiveQueries` is
* set, `prepareHistogramData` produces a single Y column, so we add exactly
* one series with the fixed merged-mode colors. Otherwise one series per
* result row, with labels resolved via the standard legend matrix.
*/
function addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
}: AddSeriesArgs): void {
const colorMapping = spec.legend?.customColors ?? {};
const mergeAllActiveQueries =
spec.histogramBuckets?.mergeAllActiveQueries ?? false;
if (mergeAllActiveQueries) {
builder.addSeries({
scaleKey: 'y',
label: '',
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
lineColor: MERGED_SERIES_LINE_COLOR,
fillColor: MERGED_SERIES_FILL_COLOR,
isDarkMode,
});
return;
}
const result = apiResponse?.payload?.data?.result;
if (!result) {
return;
}
result.forEach((series) => {
const baseLabel = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = resolveSeriesLabel(series, builderQueries, baseLabel);
builder.addSeries({
scaleKey: 'y',
label,
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
isDarkMode,
});
});
}

View File

@@ -1,148 +0,0 @@
import { histogramBucketSizes } from '@grafana/data';
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
import {
buildHistogramBuckets,
mergeAlignedDataTables,
prependNullBinToFirstHistogramSeries,
replaceUndefinedWithNullInAlignedData,
} from 'container/DashboardContainer/visualization/panels/utils/histogram';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { AlignedData } from 'uplot';
import { incrRoundDn, roundDecimals } from 'utils/round';
export interface PrepareHistogramDataArgs {
payload: MetricRangePayloadProps | undefined;
bucketWidth?: number;
bucketCount?: number;
mergeAllActiveQueries?: boolean;
}
const BUCKET_OFFSET = 0;
const sortAscending = (a: number, b: number): number => a - b;
/**
* Bins raw series values into a uPlot-aligned histogram. Picks a bucket size
* either from `bucketWidth` (explicit override) or the smallest predefined
* Grafana bucket that fits the data's `range / bucketCount` target while
* staying ≥ the data's smallest non-zero delta (so we never sub-divide below
* the resolution of the input).
*
* Empty input → `[[]]` (a valid empty AlignedData uPlot accepts).
*/
export function prepareHistogramData({
payload,
bucketWidth,
bucketCount = DEFAULT_BUCKET_COUNT,
mergeAllActiveQueries = false,
}: PrepareHistogramDataArgs): AlignedData {
if (!payload) {
return [[]];
}
const result = payload.data.result;
const values = extractNumericValues(result);
if (values.length === 0) {
return [[]];
}
const sorted = [...values].sort(sortAscending);
const range = sorted[sorted.length - 1] - sorted[0];
const smallestDelta = computeSmallestDelta(sorted);
let bucketSize = selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride: bucketWidth,
});
if (bucketSize <= 0) {
bucketSize = range > 0 ? range / bucketCount : 1;
}
const getBucket = (v: number): number =>
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
const frames = buildFrames(result, mergeAllActiveQueries);
// Merged mode folds every query into frame 0 and leaves trailing empty
// frames — drop those. Per-query mode must keep one column per result row
// (even empty queries), or the data column count drifts below the series
// count `buildHistogramConfig` adds per row → uPlot renders nothing.
const histograms: AlignedData[] = frames
.filter((frame) => !mergeAllActiveQueries || frame.length > 0)
.map((frame) => buildHistogramBuckets(frame, getBucket, sortAscending));
if (histograms.length === 0) {
return [[]];
}
const merged = mergeAlignedDataTables(histograms);
replaceUndefinedWithNullInAlignedData(merged);
prependNullBinToFirstHistogramSeries(merged, bucketSize);
return merged;
}
function extractNumericValues(
result: MetricRangePayloadProps['data']['result'],
): number[] {
const values: number[] = [];
for (const item of result) {
for (const [, valueStr] of item.values) {
values.push(Number.parseFloat(valueStr) || 0);
}
}
return values;
}
function computeSmallestDelta(sortedValues: number[]): number {
if (sortedValues.length <= 1) {
return 0;
}
let smallest = Infinity;
for (let i = 1; i < sortedValues.length; i++) {
const delta = sortedValues[i] - sortedValues[i - 1];
if (delta > 0) {
smallest = Math.min(smallest, delta);
}
}
return smallest === Infinity ? 0 : smallest;
}
function selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride,
}: {
range: number;
bucketCount: number;
smallestDelta: number;
bucketWidthOverride?: number;
}): number {
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
return bucketWidthOverride;
}
const targetSize = range / bucketCount;
for (const candidate of histogramBucketSizes) {
if (targetSize < candidate && candidate >= smallestDelta) {
return candidate;
}
}
return 0;
}
// When merging is on, fold all frames into the first; the trailing empty
// frames stay in the array so downstream `.filter(length > 0)` drops them.
function buildFrames(
result: MetricRangePayloadProps['data']['result'],
mergeAllActiveQueries: boolean,
): number[][] {
const frames: number[][] = result.map((item) =>
item.values.map(([, valueStr]) => Number.parseFloat(valueStr) || 0),
);
if (mergeAllActiveQueries && frames.length > 1) {
const first = frames[0];
for (let i = 1; i < frames.length; i++) {
first.push(...frames[i]);
frames[i] = [];
}
}
return frames;
}

View File

@@ -1,6 +0,0 @@
import type { SectionConfig } from '../types';
export const sections: SectionConfig[] = [
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'buckets', controls: { count: true } },
];

View File

@@ -1,76 +0,0 @@
import { useCallback, useMemo } from 'react';
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Pie from 'container/DashboardContainer/visualization/charts/Pie/Pie';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import PanelStyles from '../styles/panel.module.scss';
import { PanelRendererProps } from '../types';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../utils/chartAppearanceMappings';
import { preparePieData } from './data';
function PiePanelRenderer({
panelId,
panel,
data,
onClick,
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
const isDarkMode = useIsDarkMode();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/PieChartPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesPieChartPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const slices = useMemo(
() =>
preparePieData({
payload: data?.payload,
customColors: spec.legend?.customColors,
isDarkMode,
}),
[data?.payload, spec.legend?.customColors, isDarkMode],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const handleSliceClick = useCallback(
(slice: PieSlice) => {
onClick?.({ label: slice.label, value: slice.value });
},
[onClick],
);
return (
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
<Pie
data={slices}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
isDarkMode={isDarkMode}
position={legendPosition}
id={panelId}
onSliceClick={handleSliceClick}
data-testid="pie-chart"
/>
</div>
);
}
export default PiePanelRenderer;

View File

@@ -1,89 +0,0 @@
import { themeColors } from 'constants/theme';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
export interface PreparePieDataArgs {
payload: MetricRangePayloadProps | undefined;
/** Per-label colour overrides from `spec.legend.customColors`. */
customColors?: Record<string, string> | null;
isDarkMode: boolean;
}
// Local view of the scalar/table response. A pie issues a TABLE request (see
// `getGraphType`), which the API returns web-formatted: one aggregation column
// holds the value, the remaining (group) columns form the label, and each row's
// `data` is keyed by `column.id || column.name`. The generated `QueryData.table`
// types its columns loosely (no `isValueColumn`), so we cast to this precise
// shape once at the boundary rather than threading `any` through.
interface ScalarTableColumn {
name: string;
id?: string;
isValueColumn: boolean;
}
interface ScalarTableEntry {
queryName?: string;
legend?: string;
table?: {
columns: ScalarTableColumn[];
rows: { data: Record<string, string | number | null> }[];
};
}
/**
* Turns a query response into pie slices: one slice per group row.
*
* The reduced value-per-series lives in the scalar `table`, not the time-series
* `result[].values`. Because the pie's graphType is `TABLE`, the response is
* web-formatted and the table sits on `result[]`; we fall back to `newResult`
* for the non-`formatForWeb` shape. Labels come from the group column(s),
* colours honour `customColors` then fall back to a deterministic palette
* colour, and non-positive / non-numeric values are dropped.
*/
export function preparePieData({
payload,
customColors,
isDarkMode,
}: PreparePieDataArgs): PieSlice[] {
const primary = (payload?.data?.result ?? []) as unknown as ScalarTableEntry[];
const fallback = (payload?.data?.newResult?.data?.result ??
[]) as unknown as ScalarTableEntry[];
const entries = primary.some((entry) => entry.table) ? primary : fallback;
const colorMap = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;
const slices: PieSlice[] = [];
entries.forEach((entry) => {
const { table } = entry;
if (!table) {
return;
}
const valueColumn = table.columns.find((column) => column.isValueColumn);
if (!valueColumn) {
return;
}
const valueKey = valueColumn.id || valueColumn.name;
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
table.rows.forEach((row) => {
const value = Number(row.data[valueKey]);
const label =
labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.join(', ') ||
entry.legend ||
entry.queryName ||
'';
const color = customColors?.[label] ?? generateColor(label, colorMap);
slices.push({ label, value, color });
});
});
return slices.filter(
(slice) => Number.isFinite(slice.value) && slice.value > 0,
);
}

View File

@@ -1,8 +0,0 @@
import type { SectionConfig } from '../types';
// Pie has no axes, thresholds, or stacking — just value formatting and a
// legend. `mode` is omitted: the pie legend is always interactive swatches.
export const sections: SectionConfig[] = [
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'legend', controls: { position: true } },
];

View File

@@ -1,154 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { useTimezone } from 'providers/Timezone';
import { useGroupByPerQuery } from '../hooks/useGroupByPerQuery';
import { useTimeScale } from '../hooks/useTimeScale';
import PanelStyles from '../styles/panel.module.scss';
import { PanelRendererProps } from '../types';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../utils/getBuilderQueries';
import { buildTimeSeriesConfig } from './config';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function TimeSeriesPanelRenderer({
panelId,
panel,
data,
onClick,
onDragSelect,
dashboardPreference,
panelMode,
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/TimeSeriesPanel'`, so the cast is a
// documented boundary narrowing — not a blind assertion. Memoized so the
// `?? {}` fallback doesn't produce a fresh object on each render.
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
() =>
(panel?.spec?.plugin?.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
const { minTimeScale, maxTimeScale } = useTimeScale(data);
const groupByPerQuery = useGroupByPerQuery(builderQueries);
const config = useMemo(
() =>
buildTimeSeriesConfig({
panelId,
spec,
builderQueries,
apiResponse: data,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
panelId,
spec,
builderQueries,
data,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
dashboardPreference?.syncMode,
],
);
const chartData = useMemo(
() => (data?.payload ? prepareChartData(data.payload) : []),
[data?.payload],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(() => {
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
),
[panelId],
);
/**
* The uPlot key prop is the only way to force a full teardown and re-mount
* of the chart. By including the syncMode and syncFilterMode in the key,
* we ensure that changes to these preferences trigger a fresh chart instance,
* preventing stale sync settings from being inherited.
*/
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="time-series-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<TimeSeries
key={key}
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default TimeSeriesPanelRenderer;

View File

@@ -1,163 +0,0 @@
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
FILL_MODE_MAP,
LINE_INTERPOLATION_MAP,
LINE_STYLE_MAP,
resolveSpanGaps,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/chartAppearanceMappings';
import { resolveSeriesLabel } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import type { BuilderQuery } from 'types/api/v5/queryRange';
const DEFAULT_POINT_SIZE = 5;
export interface BuildTimeSeriesConfigArgs {
panelId: string;
spec: DashboardtypesTimeSeriesPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a TimeSeries panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the TimeSeries-specific concern: one series per result, with visuals
* resolved from `spec.chartAppearance`.
*/
export function buildTimeSeriesConfig({
panelId,
spec,
builderQueries,
apiResponse,
isDarkMode,
timezone,
panelMode,
onDragSelect,
onClick,
minTimeScale,
maxTimeScale,
}: BuildTimeSeriesConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.TIME_SERIES,
isDarkMode,
timezone,
panelMode,
isLogScale: spec.axes?.isLogScale,
softMin: spec.axes?.softMin ?? undefined,
softMax: spec.axes?.softMax ?? undefined,
formatting: spec.formatting,
thresholds: spec.thresholds,
apiResponse,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
});
addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesTimeSeriesPanelSpecDTO;
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
}
/**
* Adds one uPlot series per result row to the scaffolded builder. The visual
* resolution (line style, interpolation, fill mode, span gaps) reads from
* `spec.chartAppearance`; the label is resolved via the legend matrix in
* `resolveSeriesLabel`. Mutates the builder in place.
*/
function addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
}: AddSeriesArgs): void {
if (!apiResponse?.payload?.data?.result) {
return;
}
const chartAppearance = spec.chartAppearance;
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
// a defined record (it dereferences keys without a guard).
const colorMapping = spec.legend?.customColors ?? {};
const spanGaps = resolveSpanGaps(chartAppearance?.spanGaps?.fillLessThan);
const lineStyle = chartAppearance?.lineStyle
? LINE_STYLE_MAP[chartAppearance.lineStyle]
: LineStyle.Solid;
const lineInterpolation = chartAppearance?.lineInterpolation
? LINE_INTERPOLATION_MAP[chartAppearance.lineInterpolation]
: LineInterpolation.Spline;
const fillMode = chartAppearance?.fillMode
? FILL_MODE_MAP[chartAppearance.fillMode]
: FillMode.None;
apiResponse.payload.data.result.forEach((series) => {
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
const baseLabel = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = resolveSeriesLabel(series, builderQueries, baseLabel);
builder.addSeries({
scaleKey: 'y',
// A single visible point can't be drawn as a line — degrade to points
// so the user still sees the datum (matches V1 behavior).
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label,
colorMapping,
spanGaps,
lineStyle,
lineInterpolation,
showPoints: chartAppearance?.showPoints || hasSingleValidPoint,
pointSize: DEFAULT_POINT_SIZE,
fillMode,
isDarkMode,
metric: series.metric,
});
});
}

View File

@@ -1,15 +0,0 @@
import type { SectionConfig } from '../types';
export const sections: SectionConfig[] = [
{
kind: 'formatting',
controls: {
unit: true,
decimals: true,
},
},
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { lineStyle: true, fillOpacity: true } },
];

View File

@@ -1,30 +0,0 @@
import { useMemo } from 'react';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Builds a record keyed by builder-query name to that query's groupBy keys
* in the V1 `BaseAutocompleteData` shape — the shape `TimeSeries` and the
* tooltip plugin consume. Conversion from v5 `GroupByKey` lives at this one
* call site that needs the V1 shape; the rest of V2 panel code stays on
* v5 types.
*/
export function useGroupByPerQuery(
builderQueries: BuilderQuery[],
): Record<string, BaseAutocompleteData[]> {
return useMemo(() => {
const result: Record<string, BaseAutocompleteData[]> = {};
builderQueries.forEach((q) => {
if (!q.name) {
return;
}
result[q.name] = (q.groupBy ?? []).map((g) => ({
key: g.name,
dataType: g.fieldDataType as BaseAutocompleteData['dataType'],
type: (g.fieldContext as BaseAutocompleteData['type']) ?? '',
id: '',
}));
});
return result;
}, [builderQueries]);
}

View File

@@ -1,29 +0,0 @@
import { useMemo } from 'react';
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
interface TimeScale {
minTimeScale: number | undefined;
maxTimeScale: number | undefined;
}
/**
* Derives the X-axis time-scale clamps from a query-range response. Reads
* `start`/`end` off `data.params` (the request that produced this payload)
* so each panel pins to the window it actually fetched — important during
* drag-zoom transitions when the time picker has moved but new data hasn't
* arrived yet. Falls back to the global time picker via the helper when
* `data` is absent.
*/
export function useTimeScale(
data: MetricQueryRangeSuccessResponse | undefined,
): TimeScale {
return useMemo(() => {
// `data.params` is typed `unknown` on this branch; PR 11562 narrows it
// to `QueryRangeRequestV5`. Drop this cast when that lands.
const params = data?.params as QueryRangeRequestV5 | undefined;
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(params);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data]);
}

View File

@@ -1,70 +0,0 @@
import { DataSource } from 'types/common/queryBuilder';
import BarPanelRenderer from './BarPanel/Renderer';
import { sections as barSections } from './BarPanel/sections';
import HistogramPanelRenderer from './HistogramPanel/Renderer';
import { sections as histogramSections } from './HistogramPanel/sections';
import PiePanelRenderer from './PiePanel/Renderer';
import { sections as pieSections } from './PiePanel/sections';
import TimeSeriesRenderer from './TimeSeriesPanel/Renderer';
import { sections as timeSeriesSections } from './TimeSeriesPanel/sections';
import type {
PanelDefinition,
PanelKind,
PanelRegistry,
RenderablePanelDefinition,
} from './types';
const TimeSeriesPanelDef: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
Renderer: TimeSeriesRenderer,
sections: timeSeriesSections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};
const BarChartPanelDef: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
Renderer: BarPanelRenderer,
sections: barSections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};
const HistogramPanelDef: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
Renderer: HistogramPanelRenderer,
sections: histogramSections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};
const PiePanelDef: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
Renderer: PiePanelRenderer,
sections: pieSections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};
export const PANELS: PanelRegistry = {
'signoz/TimeSeriesPanel': TimeSeriesPanelDef,
'signoz/BarChartPanel': BarChartPanelDef,
'signoz/HistogramPanel': HistogramPanelDef,
'signoz/PieChartPanel': PiePanelDef,
};
export function getPanelDefinition(
kind: string | undefined,
): RenderablePanelDefinition | undefined {
if (!kind) {
return undefined;
}
// The registry is correlated by kind, so a string lookup yields a union over
// every kind's exactly-typed definition. The renderer cannot be validated
// against that union at the JSX boundary, so widen to the kind-agnostic
// surface here — the single, intentional cast for the whole panel system.
return PANELS[kind as PanelKind] as unknown as
| RenderablePanelDefinition
| undefined;
}

View File

@@ -1,59 +0,0 @@
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
/**
* Source-tagged click events. The three uPlot panels share `ChartClickEvent`;
* each non-chart kind carries the context its drill-down needs. The `source`
* tag lets a kind-agnostic consumer (the render boundary, a shared drill-down
* handler) discriminate without assuming a chart shape.
*/
export type ChartClickEvent = ChartClickData;
export type TableClickEvent = {
rowData: Record<string, unknown>;
columnId?: string;
};
export type ListClickEvent = {
rowData: Record<string, unknown>;
};
export type PieClickEvent = { label: string; value: number };
/** Union of every panel click event — switched on by `source` at the boundary. */
export type PanelClickEvent =
| ChartClickEvent
| TableClickEvent
| ListClickEvent
| PieClickEvent;
type DragSelect = (start: number, end: number) => void;
/**
* Per-kind interaction props. Each panel kind exposes ONLY the gestures it
* supports: chart panels get a chart-shaped `onClick`, time-axis charts add
* `onDragSelect`, histograms have no drag-to-zoom, a NumberPanel has no
* interactions at all. Keys mirror `PanelKind`; `PanelRendererProps<K>` in
* types.ts indexes this map, so a missing kind is a compile error there.
*/
export interface PanelInteractionMap {
'signoz/TimeSeriesPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
};
'signoz/BarChartPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
};
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
'signoz/ListPanel': { onClick?: (event: ListClickEvent) => void };
'signoz/PieChartPanel': { onClick?: (event: PieClickEvent) => void };
'signoz/NumberPanel': Record<string, never>;
}
/**
* Widest interaction surface — used where the panel kind is not known
* statically (the registry render boundary; see `getPanelDefinition`). It is
* the structural supertype the per-kind shapes are cast to exactly once.
*/
export interface AnyPanelInteractionProps {
onClick?: (event: PanelClickEvent) => void;
onDragSelect?: DragSelect;
}

View File

@@ -1,9 +0,0 @@
.panelContainer {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}

View File

@@ -1,177 +0,0 @@
import type { ComponentType } from 'react';
import {
BarChart,
Columns3,
Hash,
ListEnd,
Palette,
Ruler,
SlidersHorizontal,
} from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { DataSource } from 'types/common/queryBuilder';
import type {
AnyPanelInteractionProps,
PanelInteractionMap,
} from './interactions';
export type PanelKind =
| 'signoz/TimeSeriesPanel'
| 'signoz/BarChartPanel'
| 'signoz/NumberPanel'
| 'signoz/PieChartPanel'
| 'signoz/TablePanel'
| 'signoz/HistogramPanel'
| 'signoz/ListPanel';
// Derived from an actual icon component so the type stays exact (size is a
// constrained IconSize union, not arbitrary strings) and ForwardRef-compatible.
type SectionIcon = typeof Hash;
export interface SectionMetadata {
title: string;
icon: SectionIcon;
description?: string;
}
// Source of truth for sections. Its keys define SectionKind; its values are the
// runtime UI metadata (consumed by PanelEditor in 1.8). Adding a new section =
// one entry here + one entry in SectionControls.
export const SECTIONS = {
formatting: { title: 'Formatting', icon: Hash },
axes: { title: 'Axes', icon: Ruler },
legend: { title: 'Legend', icon: ListEnd },
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
chartAppearance: { title: 'Chart appearance', icon: Palette },
columnUnits: { title: 'Column units', icon: Columns3 },
buckets: { title: 'Buckets', icon: BarChart },
} as const satisfies Record<string, SectionMetadata>;
export type SectionKind = keyof typeof SECTIONS;
// Per-kind control toggles (type-only — runtime metadata is in SECTIONS).
// Section components type their controls prop via `SectionControls['axes']`.
export type SectionControls = {
formatting: { unit?: boolean; decimals?: boolean };
axes: { minMax?: boolean; unit?: boolean; logScale?: boolean };
legend: { position?: boolean; mode?: boolean };
thresholds: { list?: boolean };
chartAppearance: {
lineStyle?: boolean;
fillOpacity?: boolean;
stacked?: boolean;
};
columnUnits: { perColumnUnit?: boolean };
buckets: { count?: boolean; min?: boolean; max?: boolean };
};
// Discriminated union derived from SectionControls — kept in lockstep automatically.
export type SectionConfig = {
[K in SectionKind]: { kind: K; controls: SectionControls[K] };
}[SectionKind];
/**
* Dashboard-wide rendering preferences propagated down to every panel renderer
* on the same dashboard. Lets the shell push cross-panel concerns (cursor
* sync, tooltip filter mode, dashboard id for scoped state) without each
* renderer rediscovering them via hooks. All fields are optional — non-
* dashboard render contexts (PanelEditor preview, standalone view) can pass
* an empty object and the renderer will fall back to sensible defaults.
*/
export interface DashboardPreference {
/**
* Cursor-sync mode for the dashboard. Drives the uPlot tooltip plugin so
* hovering one panel highlights the corresponding x on every other panel.
*/
syncMode?: DashboardCursorSync;
/**
* Filter applied to the synced tooltip across panels (e.g. only show series
* whose label matches the hovered series).
*/
syncFilterMode?: SyncTooltipFilterMode;
/**
* Dashboard id — useful for renderers that scope per-dashboard state
* (e.g. pinned-tooltip persistence, drill-down history).
*/
dashboardId?: string;
}
// Kind-agnostic props every renderer receives, regardless of panel kind. The
// kind-specific interaction props (onClick payload, onDragSelect) are layered
// on per-kind by PanelRendererProps<K>.
export interface BaseRendererProps {
panelId: string;
/**
* The whole perses panel — renderers derive their concrete `spec` and the
* perses-shaped `queries` from this. Passing the full panel keeps the prop
* surface stable as new panel-level fields are added to the wire format.
*/
panel: DashboardtypesPanelDTO | undefined;
data: MetricQueryRangeSuccessResponse | undefined;
isLoading: boolean;
error: Error | null;
/** Gate for the drill-down right-click menu. Off by default in V2. */
enableDrillDown?: boolean;
/**
* Render context — varies behavior (e.g. dashboard widget vs. standalone
* full-screen vs. inside the editor). See PanelMode for the contract.
*/
panelMode: PanelMode;
/**
* Dashboard-level preferences that should propagate to every panel
* (cursor sync, tooltip filter mode, dashboard id). The shell owns
* resolving these; the renderer just consumes them.
*/
dashboardPreference?: DashboardPreference;
}
// Renderer props for a specific panel kind: the shared base plus that kind's
// interaction surface (PanelInteractionMap[K]). Each renderer annotates with
// its own kind — e.g. PanelRendererProps<'signoz/TimeSeriesPanel'> — so it can
// only reference the gestures that kind supports. Indexing PanelInteractionMap
// here forces the map to cover every PanelKind. The default K = PanelKind
// yields the widest surface (a union over all kinds).
export type PanelRendererProps<K extends PanelKind = PanelKind> =
BaseRendererProps & PanelInteractionMap[K];
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
Renderer: ComponentType<PanelRendererProps<K>>;
sections: SectionConfig[];
supportedSignals: DataSource[];
}
// Keyed registry that preserves the kind ↔ definition correlation: indexing
// with a literal kind yields that kind's exactly-typed PanelDefinition.
export type PanelRegistry = { [K in PanelKind]?: PanelDefinition<K> };
// A PanelDefinition whose Renderer is widened to the kind-agnostic prop surface.
// At the render boundary the concrete kind isn't known statically (a registry
// lookup returns a union over kinds), so getPanelDefinition resolves to this —
// concentrating the single unavoidable cast in one place instead of leaking it
// to every call site.
export interface RenderablePanelDefinition extends Omit<
PanelDefinition,
'Renderer'
> {
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
}
export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/TimeSeriesPanel': PANEL_TYPES.TIME_SERIES,
'signoz/BarChartPanel': PANEL_TYPES.BAR,
'signoz/NumberPanel': PANEL_TYPES.VALUE,
'signoz/PieChartPanel': PANEL_TYPES.PIE,
'signoz/TablePanel': PANEL_TYPES.TABLE,
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
'signoz/ListPanel': PANEL_TYPES.LIST,
};

View File

@@ -1,208 +0,0 @@
import type {
DashboardtypesPanelFormattingDTO,
DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import onClickPlugin, {
OnClickPluginOpts,
} from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DistributionType,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
/**
* Inputs for the shared V2 chart pipeline. Mirrors the V1 helper of the same
* name but accepts perses-shaped inputs directly (so callers don't translate
* once per panel). The series-rendering step is panel-specific and lives in
* each panel's `utils.ts` — this helper only wires the scaffolding (scales,
* thresholds, axes, drag-to-zoom, click plugin).
*/
export interface BuildBaseConfigArgs {
panelId: string;
panelType: PANEL_TYPES;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
/** From `spec.axes` — drives the Y scale and (when log) both scales' base. */
isLogScale?: boolean;
softMin?: number;
softMax?: number;
/** From `spec.formatting.unit` — drives Y axis tick formatting + threshold formatting. */
formatting?: DashboardtypesPanelFormattingDTO;
/** From `spec.thresholds` — perses shape, mapped to the draw-hook shape internally. */
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
/** Query-range response — used by the click plugin to map pixel → data. */
apiResponse: MetricQueryRangeSuccessResponse | undefined;
/** Time-range clamps for the X scale (typically from `getTimeRange(apiResponse)`). */
minTimeScale?: number;
maxTimeScale?: number;
/** Optional — histogram and other non-time panels omit drag-to-zoom. */
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
}
/**
* Builds the panel-agnostic scaffolding of a uPlot chart: scales, thresholds,
* axes, drag-to-zoom, click plugin. Callers (TimeSeriesPanel, BarPanel, …)
* then call `addSeries`/`addPlugin` on the returned builder for their own
* panel-specific rendering.
*/
export function buildBaseConfig({
panelId,
panelType,
isDarkMode,
timezone,
panelMode,
isLogScale,
softMin,
softMax,
formatting,
thresholds,
apiResponse,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
}: BuildBaseConfigArgs): UPlotConfigBuilder {
const yAxisUnit = formatting?.unit;
const builder = new UPlotConfigBuilder({
id: panelId,
onDragSelect,
tzDate: makeTzDate(timezone),
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
selectionPreferencesSource: resolveSelectionPreferencesSource(panelMode),
stepInterval: resolveStepInterval(apiResponse),
});
const thresholdOptions: ThresholdsDrawHookOptions = {
scaleKey: 'y',
thresholds: mapThresholds(thresholds),
yAxisUnit,
};
builder.addThresholds(thresholdOptions);
builder.addScale({
scaleKey: 'x',
time: true,
min: minTimeScale,
max: maxTimeScale,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
builder.addScale({
scaleKey: 'y',
time: false,
min: undefined,
max: undefined,
softMin,
softMax,
thresholds: thresholdOptions,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
if (typeof onClick === 'function') {
builder.addPlugin(
onClickPlugin({ onClick, apiResponse: apiResponse?.payload }),
);
}
builder.addAxis({
scaleKey: 'x',
show: true,
side: 2,
isDarkMode,
isLogScale,
panelType,
});
builder.addAxis({
scaleKey: 'y',
show: true,
side: 3,
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;
}
function makeTzDate(
timezone: Timezone,
): ((timestamp: number) => Date) | undefined {
if (!timezone) {
return undefined;
}
return (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
}
function resolveSelectionPreferencesSource(
panelMode: PanelMode,
): SelectionPreferencesSource {
return panelMode === PanelMode.DASHBOARD_VIEW ||
panelMode === PanelMode.STANDALONE_VIEW
? SelectionPreferencesSource.LOCAL_STORAGE
: SelectionPreferencesSource.IN_MEMORY;
}
// Perses-shape thresholds → the draw-hook shape uPlotV2 consumes. Exported so
// panels that need to feed the same threshold list elsewhere (e.g. to a series
// `addSeries` thresholds hook) don't have to redo the mapping.
export function mapThresholds(
thresholds: DashboardtypesThresholdWithLabelDTO[] | null | undefined,
): ThresholdsDrawHookOptions['thresholds'] {
if (!thresholds || thresholds.length === 0) {
return [];
}
return thresholds.map((t) => ({
thresholdValue: t.value,
thresholdColor: t.color,
thresholdUnit: t.unit,
thresholdLabel: t.label,
}));
}
/**
* V5 backend reports per-query step intervals; we feed the smallest one through
* to uPlot so the X-axis tick density matches the densest query. An empty map
* yields `Infinity` from `Math.min`, which would corrupt downstream scale math —
* fall back to `undefined` (uPlot's "auto") in that case.
*/
export function resolveStepInterval(
apiResponse: MetricQueryRangeSuccessResponse | undefined,
): number | undefined {
const stepIntervals =
apiResponse?.payload?.data?.newResult?.meta?.stepIntervals;
if (!stepIntervals) {
return undefined;
}
const values = Object.values(stepIntervals);
if (values.length === 0) {
return undefined;
}
const min = Math.min(...values);
return Number.isFinite(min) ? min : undefined;
}

View File

@@ -1,108 +0,0 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesPrecisionOptionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
/**
* Bridges the V2 dashboard wire-format enums (snake_case, generated from Go)
* to the uPlotV2 chart enums (PascalCase). String values diverge between the
* two — don't coerce, map.
*
* Kept as a single source of truth so every panel that reads chart-appearance
* fields stays in sync as either side's enum evolves.
*/
export const LINE_STYLE_MAP: Record<DashboardtypesLineStyleDTO, LineStyle> = {
[DashboardtypesLineStyleDTO.solid]: LineStyle.Solid,
[DashboardtypesLineStyleDTO.dashed]: LineStyle.Dashed,
};
export const LINE_INTERPOLATION_MAP: Record<
DashboardtypesLineInterpolationDTO,
LineInterpolation
> = {
[DashboardtypesLineInterpolationDTO.linear]: LineInterpolation.Linear,
[DashboardtypesLineInterpolationDTO.spline]: LineInterpolation.Spline,
[DashboardtypesLineInterpolationDTO.step_after]: LineInterpolation.StepAfter,
[DashboardtypesLineInterpolationDTO.step_before]: LineInterpolation.StepBefore,
};
export const FILL_MODE_MAP: Record<DashboardtypesFillModeDTO, FillMode> = {
[DashboardtypesFillModeDTO.solid]: FillMode.Solid,
[DashboardtypesFillModeDTO.gradient]: FillMode.Gradient,
[DashboardtypesFillModeDTO.none]: FillMode.None,
};
export const LEGEND_POSITION_MAP: Record<
DashboardtypesLegendPositionDTO,
LegendPosition
> = {
[DashboardtypesLegendPositionDTO.bottom]: LegendPosition.BOTTOM,
[DashboardtypesLegendPositionDTO.right]: LegendPosition.RIGHT,
};
/**
* `spec.formatting.decimalPrecision` is a stringified-digit enum on the wire
* (`'0'``'4'` plus the sentinel `'full'`). The chart consumes a numeric
* `PrecisionOption` (`0``4`) or the same `'full'` sentinel from its own
* enum. Missing / unknown → `undefined` (chart uses its default).
*/
export function resolveDecimalPrecision(
precision: DashboardtypesPrecisionOptionDTO | undefined,
): PrecisionOption | undefined {
if (!precision) {
return undefined;
}
if (precision === DashboardtypesPrecisionOptionDTO.full) {
return PrecisionOptionsEnum.FULL;
}
const parsed = Number(precision);
if (
parsed === 0 ||
parsed === 1 ||
parsed === 2 ||
parsed === 3 ||
parsed === 4
) {
return parsed;
}
return undefined;
}
/**
* `spec.chartAppearance.spanGaps.fillLessThan` is a stringified number on the
* wire. Empty / missing → span all gaps (the chart default). Numeric → forward
* the threshold so uPlot only bridges short runs of nulls.
*/
export function resolveSpanGaps(
fillLessThan: string | undefined,
): boolean | number {
if (!fillLessThan) {
return true;
}
const parsed = Number(fillLessThan);
return Number.isFinite(parsed) ? parsed : true;
}
/**
* Resolves the legend position for a panel. Missing / unknown values fall
* back to `BOTTOM` to match the chart's default and the V1 behavior.
*/
export function resolveLegendPosition(
position: DashboardtypesLegendPositionDTO | undefined,
): LegendPosition {
if (position && position in LEGEND_POSITION_MAP) {
return LEGEND_POSITION_MAP[position];
}
return LegendPosition.BOTTOM;
}

View File

@@ -1,38 +0,0 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Flattens a panel's queries into the list of builder queries it contains —
* unwrapping `CompositeQuery` envelopes along the way. Non-builder kinds
* (PromQL, ClickHouseSQL, Formula, TraceOperator) are dropped: they don't
* carry the legend / groupBy / aggregation context downstream code needs.
*
* Returns the generated v5 `BuilderQuery` shape directly — no intermediate
* summary type — so callers consume the same type the wire format defines.
*/
export function getBuilderQueries(
queries: DashboardtypesQueryDTO[] | undefined,
): BuilderQuery[] {
if (!queries) {
return [];
}
const flattened: BuilderQuery[] = [];
queries.forEach((envelope) => {
const plugin = envelope?.spec?.plugin;
if (!plugin) {
return;
}
if (plugin.kind === 'signoz/BuilderQuery') {
flattened.push(plugin.spec as BuilderQuery);
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
(plugin.spec.queries ?? []).forEach((sub) => {
if (sub.type === 'builder_query') {
flattened.push(sub.spec as BuilderQuery);
}
});
}
});
return flattened;
}

View File

@@ -1,112 +0,0 @@
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { QueryData } from 'types/api/widgets/getQuery';
/**
* Resolves the display label for one series, applying the V1 legend matrix:
* `single-vs-many builder queries × with/without groupBy × single-vs-many
* aggregations`. Returns `baseLabel` unchanged for panels without builder
* queries (PromQL, ClickHouseSQL) and for builder series whose aggregation
* carries no alias/expression — metric aggregations don't have those fields,
* so they naturally short-circuit to the base label here.
*/
export function resolveSeriesLabel(
series: QueryData,
builderQueries: BuilderQuery[],
baseLabel: string,
): string {
if (builderQueries.length === 0) {
return baseLabel;
}
const matching = builderQueries.find((q) => q.name === series.queryName);
if (!matching) {
return baseLabel;
}
// `series.metaData.index` points to the aggregation in the matched query
// that produced this series. Default to 0 so single-aggregation panels
// still resolve when the backend omits the field.
const aggIndex = series.metaData?.index ?? 0;
const aggregations = matching.aggregations ?? [];
const aggregation = aggregations[aggIndex];
// `alias` / `expression` exist on Log/Trace aggregations only —
// `MetricAggregation` carries `metricName`/`temporality`/… instead. The
// `in` guards narrow the union without a cast.
const aggregationAlias =
aggregation && 'alias' in aggregation ? (aggregation.alias ?? '') : '';
const aggregationExpression =
aggregation && 'expression' in aggregation
? (aggregation.expression ?? '')
: '';
if (!aggregationAlias && !aggregationExpression) {
return baseLabel || series.metaData?.queryName || matching.name || '';
}
const ctx: FormatContext = {
aggregationAlias,
aggregationExpression,
baseLabel,
legend: matching.legend ?? '',
hasGroupBy: (matching.groupBy?.length ?? 0) > 0,
singleAggregation: aggregations.length === 1,
};
return builderQueries.length === 1
? formatForSinglePanelQuery(ctx)
: formatForMultiplePanelQueries(ctx);
}
interface FormatContext {
aggregationAlias: string;
aggregationExpression: string;
baseLabel: string;
legend: string;
hasGroupBy: boolean;
singleAggregation: boolean;
}
// Panel has one builder query — ports V1's `getLegendForSingleAggregation`.
function formatForSinglePanelQuery({
aggregationAlias,
aggregationExpression,
baseLabel,
legend,
hasGroupBy,
singleAggregation,
}: FormatContext): string {
if (hasGroupBy) {
if (singleAggregation) {
return baseLabel;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}
if (singleAggregation) {
return aggregationAlias || legend || aggregationExpression;
}
return aggregationAlias || aggregationExpression;
}
// Panel has multiple builder queries — ports V1's `getLegendForMultipleAggregations`.
// Differs from the single-query path in two cells: the no-groupBy / single-agg
// cell falls through to `baseLabel` instead of `legend`, and the no-groupBy /
// multi-agg cell prepends the base label.
function formatForMultiplePanelQueries({
aggregationAlias,
aggregationExpression,
baseLabel,
hasGroupBy,
singleAggregation,
}: FormatContext): string {
if (hasGroupBy) {
if (singleAggregation) {
return baseLabel;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}
if (singleAggregation) {
return aggregationAlias || baseLabel || aggregationExpression;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}

View File

@@ -36,14 +36,6 @@
margin-inline-end: 0;
}
// Actions sit inside the drag-handle row but opt out of dragging
// (`panel-no-drag`); reset the grab cursor so the menu reads as clickable.
.actions {
display: flex;
align-items: center;
cursor: default;
}
.body {
flex: 1;
display: flex;
@@ -58,41 +50,3 @@
.bodyKind {
margin-bottom: 6px;
}
// Container for the rendered chart — fills the panel below the header and lets
// the chart shrink (min-* 0) so it resizes with the grid cell.
.chartBody {
flex: 1;
display: flex;
min-height: 0;
min-width: 0;
}
// Subtle background-refetch spinner in the header (chart stays mounted).
.refetchIndicator {
color: var(--l2-foreground);
flex-shrink: 0;
}
// Error state — shown only when there's no stale data to fall back to.
.error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
text-align: center;
}
.errorIcon {
color: var(--bg-cherry-500, #e5484d);
}
.errorMessage {
color: var(--l2-foreground);
font-size: 12px;
max-width: 90%;
overflow-wrap: anywhere;
}

View File

@@ -1,15 +1,15 @@
import { useMemo } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels';
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import cx from 'classnames';
import type { DashboardSection } from '../../utils';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelBody from './PanelBody';
import PanelHeader from './PanelHeader';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import styles from './Panel.module.scss';
/** Panel action context — present together only in editable sectioned mode. */
@@ -23,18 +23,16 @@ export interface PanelActionsConfig {
interface PanelProps {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/** True once this panel's section enters the viewport — gates the fetch. */
/**
* Placeholder: true once this panel's section enters the viewport. The panel
* query-loading implementation (later PR) will consume this to lazily fetch
* data. Currently unused on purpose.
*/
isVisible?: boolean;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/**
* A single dashboard panel: chrome (header) + content (body). Thin orchestrator
* — data fetching lives in `usePanelQuery`, cross-panel interactions in
* `usePanelInteractions`, and the loading/error/chart state machine in
* `PanelBody`.
*/
function Panel({
panel,
panelId,
@@ -43,22 +41,9 @@ function Panel({
}: PanelProps): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const fullKind = panel?.spec?.plugin?.kind;
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel?.spec?.queries?.length ?? 0;
const panelDef = getPanelDefinition(fullKind);
const { data, isLoading, isFetching, error, refetch } = usePanelQuery({
panel,
panelId,
// Lazy: only fetch once the section is on screen (undefined → treat as
// visible) and a renderer exists for the kind.
enabled: !!panelDef && isVisible !== false,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const headerTitle = useMemo(() => {
if (!description) {
return name;
@@ -75,26 +60,35 @@ function Panel({
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
>
<PanelHeader
title={headerTitle}
kind={kind}
panelId={panelId}
isFetching={isFetching}
panelActions={panelActions}
/>
<PanelBody
panelDef={panelDef}
panel={panel}
panelId={panelId}
kind={kind}
queryCount={queryCount}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
/>
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>
{headerTitle}
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
{panelActions ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={panelActions.currentLayoutIndex}
sections={panelActions.sections}
onMovePanel={panelActions.onMovePanel}
onDeletePanel={panelActions.onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />
)}
</div>
<div className={styles.body}>
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
coming next
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,110 +0,0 @@
import { Spin } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { Loader, TriangleAlert } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type {
DashboardPreference,
RenderablePanelDefinition,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types';
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import styles from './Panel.module.scss';
interface PanelBodyProps {
/** Resolved renderer for the panel kind; undefined when the kind is unknown. */
panelDef: RenderablePanelDefinition | undefined;
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
kind: string;
queryCount: number;
data: MetricQueryRangeSuccessResponse | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => void;
onDragSelect: (start: number, end: number) => void;
dashboardPreference: DashboardPreference;
}
/**
* Renders the panel content as an explicit state machine so each state is
* handled deliberately (no implicit fall-through):
*
* unknown-kind → unsupported fallback
* error + no data → error message with retry
* first load (no data) → loading indicator
* otherwise → the kind's renderer (which owns its own "No Data" state, and
* keeps stale data mounted during background refetches)
*/
function PanelBody({
panelDef,
panel,
panelId,
kind,
queryCount,
data,
isLoading,
error,
refetch,
onDragSelect,
dashboardPreference,
}: PanelBodyProps): JSX.Element {
if (!panelDef) {
return (
<div className={styles.body} data-testid="panel-unknown-kind-fallback">
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · not yet supported
in V2
</div>
</div>
</div>
);
}
// Surface a hard failure only when there's no (stale) data to show; otherwise
// keep the last-good chart and let the header indicate the refresh.
if (error && !data) {
return (
<div className={styles.error} data-testid="panel-error">
<TriangleAlert size={20} className={styles.errorIcon} />
<Typography.Text className={styles.errorMessage}>
{error.message || 'Failed to load panel data'}
</Typography.Text>
<Button variant="outlined" color="secondary" onClick={refetch}>
Retry
</Button>
</div>
);
}
// First load only — background refetches keep `data` populated so the chart
// stays mounted instead of blinking.
if (isLoading && !data) {
return (
<div className={styles.body} data-testid="panel-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
</div>
);
}
return (
<div className={styles.chartBody}>
<panelDef.Renderer
panelId={panelId}
panel={panel}
data={data}
isLoading={isLoading}
error={error}
onDragSelect={onDragSelect}
panelMode={PanelMode.DASHBOARD_VIEW}
enableDrillDown={false}
dashboardPreference={dashboardPreference}
/>
</div>
);
}
export default PanelBody;

View File

@@ -1,61 +0,0 @@
import type { ReactNode } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical, Loader } from '@signozhq/icons';
import cx from 'classnames';
import type { PanelActionsConfig } from './Panel';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import styles from './Panel.module.scss';
interface PanelHeaderProps {
title: ReactNode;
kind: string;
panelId: string;
/** Background refresh in flight — shows a subtle spinner without blinking the chart. */
isFetching: boolean;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/** Panel chrome: drag handle, title, kind badge, refetch indicator, actions. */
function PanelHeader({
title,
kind,
panelId,
isFetching,
panelActions,
}: PanelHeaderProps): JSX.Element {
return (
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>{title}</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
{isFetching && (
<Loader
size={12}
className={cx('animate-spin', styles.refetchIndicator)}
data-testid="panel-refetching"
/>
)}
</div>
{/* `panel-no-drag` opts this region out of the grid drag handle so the
actions menu is clickable instead of starting a panel drag. */}
<div className={cx('panel-no-drag', styles.actions)}>
{panelActions ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={panelActions.currentLayoutIndex}
sections={panelActions.sections}
onMovePanel={panelActions.onMovePanel}
onDeletePanel={panelActions.onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />
)}
</div>
</div>
);
}
export default PanelHeader;

View File

@@ -1,68 +0,0 @@
import { useCallback, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time dispatch off redux
import { useDispatch } from 'react-redux';
import { useLocation, useParams } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { UpdateTimeInterval } from 'store/actions';
export interface PanelInteractions {
/**
* Drag-select a time range on a chart → write the window to the URL + global
* time so every panel re-fetches against the same range.
*/
onDragSelect: (start: number, end: number) => void;
/**
* Dashboard-wide rendering preferences (cursor sync, tooltip filter) keyed
* off the dashboard id from the route.
*/
dashboardPreference: DashboardPreference;
}
/**
* Encapsulates the cross-panel interactions shared by every dashboard-view
* panel: drag-to-zoom time selection and the cursor-sync / tooltip-filter
* preferences. Keeping this out of the `Panel` component keeps the component a
* thin render orchestrator and lets the wiring be unit-tested in isolation.
*/
export function usePanelInteractions(): PanelInteractions {
const dispatch = useDispatch();
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const { dashboardId } = useParams<{ dashboardId: string }>();
const [syncMode] = useDashboardCursorSyncMode(
dashboardId,
PanelMode.DASHBOARD_VIEW,
);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
const dashboardPreference = useMemo<DashboardPreference>(
() => ({ syncMode, syncFilterMode, dashboardId }),
[syncMode, syncFilterMode, dashboardId],
);
const onDragSelect = useCallback(
(start: number, end: number): void => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
safeNavigate(`${pathname}?${urlQuery.toString()}`);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, safeNavigate, urlQuery],
);
return { onDragSelect, dashboardPreference };
}

View File

@@ -54,7 +54,6 @@ function SectionGrid({
useCSSTransforms
layout={rglLayout}
draggableHandle=".panel-drag-handle"
draggableCancel=".panel-no-drag"
isDraggable={isEditable}
isResizable={isEditable}
onDragStop={handleLayoutChange}

View File

@@ -1,288 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { EQueryType } from 'types/common/dashboard';
import { usePanelQuery } from '../../hooks/usePanelQuery';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: jest.fn(() => ({ start: '100', end: '200' })),
}));
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
jest.mock('hooks/queryBuilder/useGetQueryRange', () => ({
useGetQueryRange: jest.fn(),
}));
const mockUseSelector = useSelector as unknown as jest.Mock;
const mockUseGetQueryRange = useGetQueryRange as unknown as jest.Mock;
// ---- helpers ---------------------------------------------------------------
// Test fixtures are cast at the outer boundary; the perses-generated panel and
// query plugin unions are too verbose to construct field-typed inline.
function builderPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
function listPanelWithLogs(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/ListPanel', spec: {} },
queries: [
{
kind: 'LogQuery',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'logs',
filter: { expression: '' },
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
function emptyPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
function histogramPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/HistogramPanel', spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
function barPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/BarChartPanel', spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
const DEFAULT_GLOBAL_TIME = {
selectedTime: 'GLOBAL_TIME',
minTime: 1000,
maxTime: 2000,
isAutoRefreshDisabled: false,
};
beforeEach(() => {
jest.clearAllMocks();
mockUseSelector.mockImplementation((selector: unknown) => {
// usePanelQuery passes a selector `(state) => state.globalTime`.
return (
selector as (state: { globalTime: typeof DEFAULT_GLOBAL_TIME }) => unknown
)({ globalTime: DEFAULT_GLOBAL_TIME });
});
mockUseGetQueryRange.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: null,
});
});
// ---- tests -----------------------------------------------------------------
describe('usePanelQuery', () => {
it('runs fromPerses on the panel queries and forwards the V1 Query to useGetQueryRange', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.query.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(requestArg.query.builder.queryData).toHaveLength(1);
expect(requestArg.query.builder.queryData[0].queryName).toBe('A');
});
it('derives graphType=TIME_SERIES for a TimeSeries panel', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.graphType).toBe(PANEL_TYPES.TIME_SERIES);
});
it('derives graphType=LIST for a ListPanel', () => {
renderHook(() =>
usePanelQuery({ panel: listPanelWithLogs(), panelId: 'p1' }),
);
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.graphType).toBe(PANEL_TYPES.LIST);
});
// HISTOGRAM and BAR panels bin/derive from raw time-series data
// client-side, so the backend must receive `time_series` (matches V1's
// `getGraphType` translation). Without this remap, V5's
// `mapPanelTypeToRequestType` would send `distribution` for histograms,
// returning a shape `prepareHistogramData` can't bin.
it('remaps graphType=HISTOGRAM to TIME_SERIES (V1 parity)', () => {
renderHook(() => usePanelQuery({ panel: histogramPanel(), panelId: 'p1' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.graphType).toBe(PANEL_TYPES.TIME_SERIES);
});
it('remaps graphType=BAR to TIME_SERIES (V1 parity)', () => {
renderHook(() => usePanelQuery({ panel: barPanel(), panelId: 'p1' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.graphType).toBe(PANEL_TYPES.TIME_SERIES);
});
it('passes V5 entity version to useGetQueryRange', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [, version] = mockUseGetQueryRange.mock.calls[0];
expect(version).toBe(ENTITY_VERSION_V5);
});
it('exposes data/error from useGetQueryRange', () => {
mockUseGetQueryRange.mockReturnValue({
data: { payload: 'X' } as unknown,
isLoading: false,
isFetching: false,
error: new Error('boom'),
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.data).toStrictEqual({ payload: 'X' });
expect(result.current.error?.message).toBe('boom');
});
it('combines isLoading and isFetching into a single isLoading flag', () => {
mockUseGetQueryRange.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: true,
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.isLoading).toBe(true);
});
it('coerces a missing/undefined error to null', () => {
mockUseGetQueryRange.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: undefined,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.error).toBeNull();
});
it('passes enabled=false to useGetQueryRange when the caller disables it', () => {
renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1', enabled: false }),
);
const [, , opts] = mockUseGetQueryRange.mock.calls[0];
expect(opts.enabled).toBe(false);
});
it('auto-disables the fetch when the panel has no queries (even with enabled=true)', () => {
renderHook(() =>
usePanelQuery({ panel: emptyPanel(), panelId: 'p1', enabled: true }),
);
const [, , opts] = mockUseGetQueryRange.mock.calls[0];
expect(opts.enabled).toBe(false);
});
it('composes a react-query cache key that includes panelId, time range, kind, and queries', () => {
const panel = builderPanel();
renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
const [, , opts] = mockUseGetQueryRange.mock.calls[0];
expect(opts.queryKey).toStrictEqual(
expect.arrayContaining([
'p1',
DEFAULT_GLOBAL_TIME.minTime,
DEFAULT_GLOBAL_TIME.maxTime,
DEFAULT_GLOBAL_TIME.selectedTime,
'signoz/TimeSeriesPanel',
panel.spec?.queries,
]),
);
});
it('forwards the inflated empty composite to useGetQueryRange when panel is undefined (no crash)', () => {
renderHook(() => usePanelQuery({ panel: undefined, panelId: 'p-none' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.query.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(requestArg.query.builder.queryData).toStrictEqual([]);
});
});

View File

@@ -1,130 +0,0 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
import { useSelector } from 'react-redux';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryAdapter/fromPerses';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { AppState } from 'store/reducers';
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType } from 'utils/getGraphType';
import { PANEL_KIND_TO_PANEL_TYPE, type PanelKind } from '../Panels/types';
export interface UsePanelQueryArgs {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
* Gate the underlying fetch. Defaults to true. PanelV2 sets this false when
* no plugin is registered for the panel's kind so the unknown-kind fallback
* UI doesn't trigger a wasted API call.
*
* The hook *also* auto-disables internally when the panel has no queries —
* callers don't need to compute that themselves.
*/
enabled?: boolean;
}
export interface UsePanelQueryResult {
data: MetricQueryRangeSuccessResponse | undefined;
/** Combines `isLoading` (first fetch) and `isFetching` (background refresh). */
isLoading: boolean;
/** Background refresh in flight while data is already present. */
isFetching: boolean;
error: Error | null;
/** Re-run the query (e.g. a retry button on the error state). */
refetch: () => void;
}
/**
* Fetches the query-range data for a V2 panel.
*
* Encapsulates three concerns the dashboard shell otherwise has to wire by
* hand at every call site:
*
* 1. Adapter — runs `fromPerses` on the panel's perses queries to produce
* the V1 in-memory Query shape `useGetQueryRange` still consumes
* internally. This is a fetch-time detail; the V1 Query is not surfaced
* to callers — renderers that need it derive it themselves from
* `panel.spec.queries` (single source of truth).
* 2. Time + variables — reads the global time selection from Redux
* (variables substitution is intentionally deferred until V2 has its
* own variable plumbing).
* 3. Fetch — calls `useGetQueryRange` with the v5 entity version and a
* react-query cache key composed from panel identity + time range +
* kind + queries so cache invalidation matches the inputs that affect
* the result.
*
* The hook is consumed today by PanelV2 (renderer dispatch) and will be
* consumed by PanelEditor (1.8) for "preview as you edit."
*/
export function usePanelQuery({
panel,
panelId,
enabled = true,
}: UsePanelQueryArgs): UsePanelQueryResult {
const fullKind = panel?.spec?.plugin?.kind;
// HISTOGRAM and BAR panels both bin/derive from raw time-series data
// client-side, so the backend `requestType` for them is `time_series`.
// `getGraphType` encodes the V1-established mapping — using it keeps
// V2 in lockstep with how the API has always been called.
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
PANEL_TYPES.TIME_SERIES;
const graphType = getGraphType(panelType);
const query = useMemo(
() => fromPerses(panel?.spec?.queries ?? []),
[panel?.spec?.queries],
);
const {
selectedTime: globalSelectedInterval,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const hasQuery = useMemo(() => {
return (
query.builder.queryData.length > 0 ||
query.promql.length > 0 ||
query.clickhouse_sql.length > 0
);
}, [query]);
const response = useGetQueryRange(
{
query,
graphType,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval,
},
ENTITY_VERSION_V5,
{
queryKey: [
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
panelId,
minTime,
maxTime,
globalSelectedInterval,
fullKind,
panel?.spec?.queries,
],
enabled: enabled && hasQuery,
},
);
return {
data: response.data,
isLoading: response.isLoading || response.isFetching,
isFetching: response.isFetching,
// Coerce undefined → null so the contract is `Error | null`, not
// `Error | null | undefined`. Consumers can rely on a single
// "no error" sentinel.
error: (response.error as Error | null) ?? null,
refetch: response.refetch,
};
}

View File

@@ -4,7 +4,7 @@ import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
import type { GridItem } from './utils';
@@ -16,7 +16,7 @@ import type { GridItem } from './utils';
* patches in DashboardSettings/General and DashboardDescription).
*/
const { add, replace, remove } = DashboardtypesPatchOpDTO;
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
const PANEL_REF_PREFIX = '#/spec/panels/';

View File

@@ -1,741 +0,0 @@
import {
Querybuildertypesv5RequestTypeDTO,
type DashboardtypesQueryDTO,
type DashboardtypesQueryPluginDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { fromPerses } from '../fromPerses';
import { toPerses } from '../toPerses';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: jest.fn(() => ({ start: '100', end: '200' })),
}));
// ---- helpers ---------------------------------------------------------------
const EMPTY_INFLATED = {
id: '',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
};
function persesBuilderDTO(
name = 'A',
signal: 'metrics' | 'logs' | 'traces' = 'metrics',
extra: Record<string, unknown> = {},
): DashboardtypesQueryDTO {
return {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name,
signal,
disabled: false,
filter: { expression: '' },
...extra,
},
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}
function extractPlugin(dto: DashboardtypesQueryDTO): {
kind: string;
spec: Record<string, unknown>;
} {
const plugin = dto.spec?.plugin;
if (!plugin) {
throw new Error('missing plugin on perses DTO');
}
return plugin as unknown as { kind: string; spec: Record<string, unknown> };
}
// ---- Phase 1: empty / unknown contract -------------------------------------
describe('fromPerses (Phase 1 — empty / unknown-input contract)', () => {
it('returns the fully-inflated empty composite on empty input', () => {
expect(fromPerses([])).toStrictEqual(EMPTY_INFLATED);
});
it('returns the fully-inflated empty composite when plugin is missing', () => {
const dto: DashboardtypesQueryDTO = {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {},
};
expect(fromPerses([dto])).toStrictEqual(EMPTY_INFLATED);
});
it('returns the fully-inflated empty composite on unknown plugin kind', () => {
const dto: DashboardtypesQueryDTO = {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/NotAThing',
spec: {},
} as unknown as DashboardtypesQueryPluginDTO,
},
};
expect(fromPerses([dto])).toStrictEqual(EMPTY_INFLATED);
});
});
// ---- Phase 2: bare BuilderQuery --------------------------------------------
describe('fromPerses (Phase 2 — bare signoz/BuilderQuery)', () => {
it('unwraps a bare BuilderQuery into a single inflated queryData entry', () => {
const result = fromPerses([persesBuilderDTO('A', 'metrics')]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData).toHaveLength(1);
expect(result.builder.queryFormulas).toStrictEqual([]);
expect(result.builder.queryTraceOperator).toStrictEqual([]);
expect(result.promql).toStrictEqual([]);
expect(result.clickhouse_sql).toStrictEqual([]);
const q = result.builder.queryData[0];
expect(q.queryName).toBe('A');
expect(q.expression).toBe('A');
});
it('maps signal "logs" to DataSource.LOGS', () => {
const q = fromPerses([persesBuilderDTO('A', 'logs')]).builder.queryData[0];
expect(q.dataSource).toBe(DataSource.LOGS);
});
it('maps signal "traces" to DataSource.TRACES', () => {
const q = fromPerses([persesBuilderDTO('A', 'traces')]).builder.queryData[0];
expect(q.dataSource).toBe(DataSource.TRACES);
});
it('maps signal "metrics" to DataSource.METRICS', () => {
const q = fromPerses([persesBuilderDTO('A', 'metrics')]).builder.queryData[0];
expect(q.dataSource).toBe(DataSource.METRICS);
});
it('applies inverse groupBy renames (name->key, fieldDataType->dataType, fieldContext->type)', () => {
const dto = persesBuilderDTO('A', 'metrics', {
groupBy: [
{
name: 'service.name',
fieldDataType: 'string',
fieldContext: 'tag',
},
],
});
const q = fromPerses([dto]).builder.queryData[0];
expect(q.groupBy[0]).toMatchObject({
key: 'service.name',
dataType: 'string',
type: 'tag',
});
});
it('applies inverse orderBy renames (key.name->columnName, direction->order)', () => {
const dto = persesBuilderDTO('A', 'metrics', {
order: [{ key: { name: 'timestamp' }, direction: 'desc' }],
});
const q = fromPerses([dto]).builder.queryData[0];
expect(q.orderBy).toStrictEqual([{ columnName: 'timestamp', order: 'desc' }]);
});
it('preserves filter.expression', () => {
const dto = persesBuilderDTO('A', 'metrics', {
filter: { expression: 'service.name = "api"' },
});
const q = fromPerses([dto]).builder.queryData[0];
expect(q.filter).toStrictEqual({ expression: 'service.name = "api"' });
});
it('coerces v5 stepInterval string (e.g. "30s") to null without crashing', () => {
const dto = persesBuilderDTO('A', 'metrics', { stepInterval: '30s' });
const q = fromPerses([dto]).builder.queryData[0];
expect(q.stepInterval).toBeNull();
});
it('keeps v5 stepInterval as-is when it is already a number', () => {
const dto = persesBuilderDTO('A', 'metrics', { stepInterval: 60 });
const q = fromPerses([dto]).builder.queryData[0];
expect(q.stepInterval).toBe(60);
});
});
// ---- Phase 2: round-trip ---------------------------------------------------
describe('round-trip (Phase 2 — bare BuilderQuery): perses → fromPerses → toPerses → perses', () => {
it('preserves a metrics builder with filter + groupBy + order', () => {
const original = persesBuilderDTO('A', 'metrics', {
filter: { expression: 'service.name = "api"' },
groupBy: [
{ name: 'service.name', fieldDataType: 'string', fieldContext: 'tag' },
],
order: [{ key: { name: 'timestamp' }, direction: 'desc' }],
});
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0].kind).toBe(
Querybuildertypesv5RequestTypeDTO.time_series,
);
const origPlugin = extractPlugin(original);
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe(origPlugin.kind);
expect(rtPlugin.spec).toMatchObject({
name: 'A',
signal: 'metrics',
filter: { expression: 'service.name = "api"' },
groupBy: origPlugin.spec.groupBy,
order: origPlugin.spec.order,
});
});
it('preserves a logs builder routed to a LIST panel (outer kind raw)', () => {
const original = persesBuilderDTO('A', 'logs', {
aggregations: [{ expression: 'count()' }],
});
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.LIST,
});
expect(roundTripped[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.raw);
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/BuilderQuery');
expect(rtPlugin.spec).toMatchObject({ name: 'A', signal: 'logs' });
});
});
// ---- Phase 3: CompositeQuery distribution ----------------------------------
function compositeDTO(
subqueries: Array<{ type: string; spec: Record<string, unknown> }>,
): DashboardtypesQueryDTO {
return {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: { queries: subqueries },
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}
describe('fromPerses (Phase 3 — signoz/CompositeQuery)', () => {
it('distributes multiple builder_query subqueries into builder.queryData', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{
type: 'builder_query',
spec: { name: 'B', signal: 'metrics', filter: { expression: '' } },
},
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual([
'A',
'B',
]);
expect(result.builder.queryFormulas).toStrictEqual([]);
expect(result.builder.queryTraceOperator).toStrictEqual([]);
});
it('handles an empty CompositeQuery (queries: []) by returning the inflated empty shape', () => {
const dto = compositeDTO([]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData).toStrictEqual([]);
});
});
// ---- Phase 3: round-trip ---------------------------------------------------
describe('round-trip (Phase 3 — multi-builder CompositeQuery): perses → fromPerses → toPerses → perses', () => {
it('preserves a two-builder CompositeQuery (same outer kind, same subquery names)', () => {
const original = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{
type: 'builder_query',
spec: { name: 'B', signal: 'metrics', filter: { expression: '' } },
},
]);
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0].kind).toBe(
Querybuildertypesv5RequestTypeDTO.time_series,
);
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/CompositeQuery');
const subqueries = rtPlugin.spec.queries as Array<{
type: string;
spec: { name?: string };
}>;
expect(subqueries.map((s) => `${s.type}:${s.spec.name}`)).toStrictEqual([
'builder_query:A',
'builder_query:B',
]);
});
});
// ---- Phase 4: bare Formula + TraceOperator + composite subqueries ----------
function bareDTO(
pluginKind: string,
spec: Record<string, unknown>,
): DashboardtypesQueryDTO {
return {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: pluginKind,
spec,
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}
describe('fromPerses (Phase 4 — invalid bare Formula / TraceOperator are warned and dropped)', () => {
let warnSpy: jest.SpyInstance;
beforeEach(() => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
warnSpy.mockRestore();
});
it('warns and returns inflated empty composite on top-level signoz/Formula (invalid alone)', () => {
const dto = bareDTO('signoz/Formula', {
name: 'F1',
expression: 'A + 1',
legend: 'L',
});
const result = fromPerses([dto]);
expect(result.builder.queryFormulas).toStrictEqual([]);
expect(result.builder.queryData).toStrictEqual([]);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(/top-level signoz\/Formula is invalid/),
);
});
it('warns and returns inflated empty composite on top-level signoz/TraceOperator (invalid alone)', () => {
const dto = bareDTO('signoz/TraceOperator', {
name: 'T1',
signal: 'traces',
expression: 'A && B',
filter: { expression: '' },
});
const result = fromPerses([dto]);
expect(result.builder.queryTraceOperator).toStrictEqual([]);
expect(result.builder.queryData).toStrictEqual([]);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(/top-level signoz\/TraceOperator is invalid/),
);
});
});
describe('fromPerses (Phase 4 — composite subquery distribution)', () => {
it('distributes builder_query + builder_formula + builder_trace_operator subqueries into their respective buckets', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A + 1' } },
{
type: 'builder_trace_operator',
spec: {
name: 'T1',
signal: 'traces',
expression: 'A',
filter: { expression: '' },
},
},
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual(['A']);
expect(result.builder.queryFormulas.map((f) => f.queryName)).toStrictEqual([
'F1',
]);
expect(
result.builder.queryTraceOperator.map((t) => t.queryName),
).toStrictEqual(['T1']);
});
});
// ---- Phase 4: round-trips --------------------------------------------------
describe('round-trip (Phase 4): mixed composite', () => {
beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
(console.warn as jest.Mock).mockRestore?.();
});
it('round-trips a CompositeQuery containing builder + formula + trace operator', () => {
const original = compositeDTO([
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
disabled: false,
},
},
{
type: 'builder_formula',
spec: { name: 'F1', expression: 'A + 1', disabled: false },
},
{
type: 'builder_trace_operator',
spec: {
name: 'T1',
signal: 'traces',
expression: 'A',
filter: { expression: '' },
disabled: false,
aggregations: [{ expression: 'count()' }],
},
},
]);
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/CompositeQuery');
const subqueries = rtPlugin.spec.queries as Array<{
type: string;
spec: { name?: string };
}>;
expect(
subqueries.map((s) => `${s.type}:${s.spec.name}`).sort(),
).toStrictEqual(
[
'builder_query:A',
'builder_formula:F1',
'builder_trace_operator:T1',
].sort(),
);
});
});
// ---- Phase 5: PromQL + ClickHouseSQL + composite queryType resolution ------
describe('fromPerses (Phase 5 — bare signoz/PromQLQuery)', () => {
it('unwraps bare PromQL into promql[0] and sets queryType=PROM', () => {
const dto = bareDTO('signoz/PromQLQuery', {
name: 'P1',
query: 'up',
disabled: false,
legend: 'L',
});
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.PROM);
expect(result.promql).toStrictEqual([
{ name: 'P1', query: 'up', disabled: false, legend: 'L' },
]);
expect(result.builder.queryData).toStrictEqual([]);
expect(result.clickhouse_sql).toStrictEqual([]);
});
it('defaults disabled to false and legend to empty when absent on wire', () => {
const dto = bareDTO('signoz/PromQLQuery', { name: 'P1', query: 'up' });
const result = fromPerses([dto]);
expect(result.promql[0]).toStrictEqual({
name: 'P1',
query: 'up',
disabled: false,
legend: '',
});
});
});
describe('fromPerses (Phase 5 — bare signoz/ClickHouseSQL)', () => {
it('unwraps bare ClickHouseSQL into clickhouse_sql[0] and sets queryType=CLICKHOUSE', () => {
const dto = bareDTO('signoz/ClickHouseSQL', {
name: 'C1',
query: 'SELECT 1',
disabled: false,
legend: '',
});
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.CLICKHOUSE);
expect(result.clickhouse_sql).toStrictEqual([
{ name: 'C1', query: 'SELECT 1', disabled: false, legend: '' },
]);
expect(result.builder.queryData).toStrictEqual([]);
expect(result.promql).toStrictEqual([]);
});
});
describe('fromPerses (Phase 4 — composite without builder warns about invalid state)', () => {
let warnSpy: jest.SpyInstance;
beforeEach(() => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
warnSpy.mockRestore();
});
it('warns when CompositeQuery has formulas/trace-ops but no builder_query subquery', () => {
const dto = compositeDTO([
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A + 1' } },
]);
fromPerses([dto]);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(
/CompositeQuery contains formulas\/trace-operators but no builder_query/,
),
);
});
it('does not warn when CompositeQuery has at least one builder_query alongside the formula', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A + 1' } },
]);
fromPerses([dto]);
expect(warnSpy).not.toHaveBeenCalled();
});
});
describe('fromPerses (Phase 5 — composite queryType resolution)', () => {
it('resolves all-promql composite to queryType=PROM', () => {
const dto = compositeDTO([
{ type: 'promql', spec: { name: 'P1', query: 'up' } },
{ type: 'promql', spec: { name: 'P2', query: 'rate(x[1m])' } },
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.PROM);
expect(result.promql.map((p) => p.name)).toStrictEqual(['P1', 'P2']);
});
it('resolves all-clickhouse composite to queryType=CLICKHOUSE', () => {
const dto = compositeDTO([
{ type: 'clickhouse_sql', spec: { name: 'C1', query: 'SELECT 1' } },
{ type: 'clickhouse_sql', spec: { name: 'C2', query: 'SELECT 2' } },
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.CLICKHOUSE);
expect(result.clickhouse_sql.map((c) => c.name)).toStrictEqual(['C1', 'C2']);
});
it('falls back to queryType=QUERY_BUILDER for a mixed-type composite', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{ type: 'promql', spec: { name: 'P1', query: 'up' } },
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData).toHaveLength(1);
expect(result.promql).toHaveLength(1);
});
});
// ---- Phase 5: round-trips --------------------------------------------------
describe('round-trip (Phase 5): PromQL / ClickHouseSQL bare + multi', () => {
it('round-trips bare signoz/PromQLQuery', () => {
const original = bareDTO('signoz/PromQLQuery', {
name: 'P1',
query: 'up',
disabled: false,
legend: 'L',
});
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/PromQLQuery');
expect(rtPlugin.spec).toMatchObject({
name: 'P1',
query: 'up',
legend: 'L',
});
});
it('round-trips bare signoz/ClickHouseSQL', () => {
const original = bareDTO('signoz/ClickHouseSQL', {
name: 'C1',
query: 'SELECT 1',
disabled: false,
legend: 'L',
});
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/ClickHouseSQL');
expect(rtPlugin.spec).toMatchObject({
name: 'C1',
query: 'SELECT 1',
legend: 'L',
});
});
it('round-trips an all-promql CompositeQuery (preserves PROM queryType)', () => {
const original = compositeDTO([
{ type: 'promql', spec: { name: 'P1', query: 'up' } },
{ type: 'promql', spec: { name: 'P2', query: 'rate(x[1m])' } },
]);
const v1 = fromPerses([original]);
expect(v1.queryType).toBe(EQueryType.PROM);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/CompositeQuery');
const subqueries = rtPlugin.spec.queries as Array<{ type: string }>;
expect(subqueries.map((s) => s.type)).toStrictEqual(['promql', 'promql']);
});
});
// ---- Phase 6: edge cases ---------------------------------------------------
describe('fromPerses (Phase 6 — edge cases)', () => {
it('returns inflated empty composite when plugin is present but spec is missing', () => {
const dto: DashboardtypesQueryDTO = {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
// spec deliberately omitted
} as unknown as DashboardtypesQueryPluginDTO,
},
};
expect(fromPerses([dto])).toStrictEqual(EMPTY_INFLATED);
});
it('handles CompositeQuery with queries: null without crashing', () => {
const dto: DashboardtypesQueryDTO = {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: { queries: null },
} as unknown as DashboardtypesQueryPluginDTO,
},
};
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder).toStrictEqual({
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
});
});
it('silently ignores composite subqueries with unrecognized types', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{ type: 'future_kind' as never, spec: { foo: 'bar' } },
]);
const result = fromPerses([dto]);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual(['A']);
});
it('skips composite subqueries that have no spec without crashing', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{
type: 'builder_formula',
spec: undefined as unknown as Record<string, unknown>,
},
]);
expect(() => fromPerses([dto])).not.toThrow();
const result = fromPerses([dto]);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual(['A']);
expect(result.builder.queryFormulas).toStrictEqual([]);
});
it('only consumes the first persesQueries entry (defensive against backend invariant violations)', () => {
const builderDTO = bareDTO('signoz/BuilderQuery', {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
});
const promDTO = bareDTO('signoz/PromQLQuery', { name: 'P1', query: 'up' });
const result = fromPerses([builderDTO, promDTO]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual(['A']);
expect(result.promql).toStrictEqual([]);
});
it('produces a sensible default V1 builder when the v5 spec is minimal (signal only)', () => {
const dto = bareDTO('signoz/BuilderQuery', { signal: 'metrics' });
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData).toHaveLength(1);
const q = result.builder.queryData[0];
expect(q.dataSource).toBe(DataSource.METRICS);
// name is generated from the default builder when v5 spec lacks one
expect(typeof q.queryName).toBe('string');
expect(q.queryName.length).toBeGreaterThan(0);
});
});

View File

@@ -1,186 +0,0 @@
/**
* Fixture-driven round-trip suite. Loads the canonical perses dashboard testdata
* the Go backend uses to validate its serialization, then for each panel runs:
*
* fromPerses(panel.queries) → V1 Query → toPerses(query, graphType) → DTO
*
* and asserts the structural fields that should survive the round trip. This
* covers real-world panel/query combinations the synthetic unit tests miss.
*/
import fs from 'fs';
import path from 'path';
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types';
import { fromPerses } from '../fromPerses';
import { toPerses } from '../toPerses';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: jest.fn(() => ({ start: '100', end: '200' })),
}));
const TESTDATA_PATH = path.join(
__dirname,
'../../../../../../../pkg/types/dashboardtypes/testdata/perses.json',
);
interface PersesPanel {
spec?: {
plugin?: { kind?: string };
queries?: DashboardtypesQueryDTO[];
};
}
interface PersesDashboardSpec {
panels?: Record<string, PersesPanel | null>;
}
interface FixturePanel {
panelId: string;
panelKind: string;
innerKind: string;
query: DashboardtypesQueryDTO;
}
function loadFixturePanels(): FixturePanel[] {
const raw = fs.readFileSync(TESTDATA_PATH, 'utf8');
const data = JSON.parse(raw) as PersesDashboardSpec;
const panels = data.panels ?? {};
return Object.entries(panels).flatMap(([panelId, panel]) => {
const panelKind = panel?.spec?.plugin?.kind;
const query = panel?.spec?.queries?.[0];
const innerKind = (query?.spec?.plugin as { kind?: string } | undefined)
?.kind;
if (!panelKind || !query || !innerKind) {
return [];
}
return [{ panelId, panelKind, innerKind, query }];
});
}
describe('round-trip: perses.json testdata → fromPerses → toPerses', () => {
const fixturePanels = loadFixturePanels();
it('testdata loaded with expected coverage', () => {
expect(fixturePanels.length).toBeGreaterThan(0);
// Sanity: at least TimeSeriesPanel + BuilderQuery is present in the testdata.
expect(
fixturePanels.some(
(p) =>
p.panelKind === 'signoz/TimeSeriesPanel' &&
p.innerKind === 'signoz/BuilderQuery',
),
).toBe(true);
});
it.each(fixturePanels)(
'panel $panelId ($panelKind / $innerKind) survives round-trip',
({ panelKind, query }) => {
const graphType =
PANEL_KIND_TO_PANEL_TYPE[panelKind as PanelKind] ?? PANEL_TYPES.TIME_SERIES;
const v1 = fromPerses([query]);
const roundTripped = toPerses({ query: v1, graphType });
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0].kind).toBe(query.kind);
const origPlugin = (query.spec?.plugin ?? {}) as {
kind?: string;
spec?: Record<string, unknown>;
};
const rtPlugin = (roundTripped[0].spec?.plugin ?? {}) as {
kind?: string;
spec?: Record<string, unknown>;
};
expect(rtPlugin.kind).toBe(origPlugin.kind);
const oSpec = origPlugin.spec ?? {};
const rSpec = rtPlugin.spec ?? {};
switch (origPlugin.kind) {
case 'signoz/BuilderQuery':
expectBuilderShapePreserved(oSpec, rSpec);
break;
case 'signoz/CompositeQuery':
expectCompositeShapePreserved(oSpec, rSpec);
break;
case 'signoz/PromQLQuery':
case 'signoz/ClickHouseSQL':
expectNamedQueryPreserved(oSpec, rSpec);
break;
default:
break;
}
},
);
});
// ---- assertion helpers -----------------------------------------------------
function namesOrEmpty(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return (value as Array<{ name?: string }>).map((g) => g.name ?? '');
}
function orderColumnsOrEmpty(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return (value as Array<{ key?: { name?: string } }>).map(
(o) => o.key?.name ?? '',
);
}
function expectBuilderShapePreserved(
original: Record<string, unknown>,
roundTripped: Record<string, unknown>,
): void {
expect(roundTripped.name).toBe(original.name);
expect(roundTripped.signal).toBe(original.signal);
const origFilterExpr = (original.filter as { expression?: string } | undefined)
?.expression;
const rtFilterExpr = (
roundTripped.filter as { expression?: string } | undefined
)?.expression;
expect(rtFilterExpr ?? '').toBe(origFilterExpr ?? '');
expect(namesOrEmpty(roundTripped.groupBy)).toStrictEqual(
namesOrEmpty(original.groupBy),
);
expect(orderColumnsOrEmpty(roundTripped.order)).toStrictEqual(
orderColumnsOrEmpty(original.order),
);
}
function expectCompositeShapePreserved(
original: Record<string, unknown>,
roundTripped: Record<string, unknown>,
): void {
const origSubs =
(original.queries as Array<{ type: string }> | undefined) ?? [];
const rtSubs =
(roundTripped.queries as Array<{ type: string }> | undefined) ?? [];
expect(rtSubs).toHaveLength(origSubs.length);
expect(rtSubs.map((s) => s.type).sort()).toStrictEqual(
origSubs.map((s) => s.type).sort(),
);
}
function expectNamedQueryPreserved(
original: Record<string, unknown>,
roundTripped: Record<string, unknown>,
): void {
expect(roundTripped.name).toBe(original.name);
expect(roundTripped.query).toBe(original.query);
}

View File

@@ -1,517 +0,0 @@
import {
Querybuildertypesv5RequestTypeDTO,
type DashboardtypesQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { toPerses } from '../toPerses';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: jest.fn(() => ({ start: '100', end: '200' })),
}));
// ---- helpers ---------------------------------------------------------------
function emptyQuery(): Query {
return {
id: 'q-empty',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
};
}
function metricsBuilderQuery(
overrides?: Partial<IBuilderQuery>,
): IBuilderQuery {
return {
queryName: 'A',
dataSource: DataSource.METRICS,
aggregations: [
{
metricName: 'cpu_usage',
temporality: '',
timeAggregation: 'sum',
spaceAggregation: 'avg',
reduceTo: ReduceOperators.AVG,
},
],
timeAggregation: 'sum',
spaceAggregation: 'avg',
temporality: '',
functions: [],
filter: { expression: '' },
filters: { items: [], op: 'AND' },
groupBy: [],
expression: 'A',
disabled: false,
having: [],
limit: null,
stepInterval: 60,
orderBy: [],
reduceTo: ReduceOperators.AVG,
legend: '',
...overrides,
} as IBuilderQuery;
}
function builderShell(builderQueries: IBuilderQuery[]): Query {
return {
id: 'q-test',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: builderQueries,
queryFormulas: [],
queryTraceOperator: [],
},
};
}
type PluginShape = { kind: string; spec: Record<string, unknown> };
function getPlugin(result: DashboardtypesQueryDTO[]): PluginShape {
const plugin = result[0]?.spec?.plugin;
if (!plugin) {
throw new Error('toPerses returned no plugin');
}
return plugin as unknown as PluginShape;
}
// ---- Phase 1: empty contract -----------------------------------------------
describe('toPerses (Phase 1 — empty input contract)', () => {
it('returns empty array when the V1 query has no queries of any kind', () => {
const result = toPerses({
query: emptyQuery(),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toStrictEqual([]);
});
});
// ---- Phase 2: bare BuilderQuery --------------------------------------------
describe('toPerses (Phase 2 — bare signoz/BuilderQuery)', () => {
it('wraps a single metrics builder query as bare signoz/BuilderQuery on a TimeSeries panel', () => {
const result = toPerses({
query: builderShell([metricsBuilderQuery()]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
expect(result[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
const plugin = getPlugin(result);
expect(plugin.kind).toBe('signoz/BuilderQuery');
expect(plugin.spec).toMatchObject({ name: 'A', signal: 'metrics' });
});
it('emits signal "logs" for a logs-source builder query', () => {
const aggregations = [
{ expression: 'count()' },
] as unknown as IBuilderQuery['aggregations'];
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ dataSource: DataSource.LOGS, aggregations }),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(getPlugin(result).spec).toMatchObject({ signal: 'logs' });
});
it('emits signal "traces" for a traces-source builder query', () => {
const aggregations = [
{ expression: 'count()' },
] as unknown as IBuilderQuery['aggregations'];
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ dataSource: DataSource.TRACES, aggregations }),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(getPlugin(result).spec).toMatchObject({ signal: 'traces' });
});
it('applies groupBy field renames (key->name, dataType->fieldDataType, type->fieldContext)', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({
groupBy: [
{
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
id: 'service.name--string--tag--false',
},
],
}),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
const groupBy = getPlugin(result).spec.groupBy as Array<{
name: string;
fieldDataType?: string;
fieldContext?: string;
}>;
expect(groupBy[0]).toMatchObject({
name: 'service.name',
fieldDataType: 'string',
fieldContext: 'tag',
});
});
it('applies orderBy field renames (columnName/order -> key.name/direction)', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
}),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
const order = getPlugin(result).spec.order as Array<{
key: { name: string };
direction: string;
}>;
expect(order[0]).toStrictEqual({
key: { name: 'timestamp' },
direction: 'desc',
});
});
it('preserves filter.expression', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({
filter: { expression: 'service.name = "api"' },
}),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(getPlugin(result).spec.filter).toStrictEqual({
expression: 'service.name = "api"',
});
});
it('derives outer kind "raw" for a LIST panel', () => {
const aggregations = [
{ expression: 'count()' },
] as unknown as IBuilderQuery['aggregations'];
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ dataSource: DataSource.LOGS, aggregations }),
]),
graphType: PANEL_TYPES.LIST,
});
expect(result[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.raw);
expect(getPlugin(result).kind).toBe('signoz/BuilderQuery');
});
});
// ---- Phase 3: CompositeQuery wrapping --------------------------------------
describe('toPerses (Phase 3 — signoz/CompositeQuery)', () => {
it('wraps two builder queries as signoz/CompositeQuery with two builder_query subqueries', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ queryName: 'A' }),
metricsBuilderQuery({ queryName: 'B' }),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
expect(result[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
const plugin = getPlugin(result);
expect(plugin.kind).toBe('signoz/CompositeQuery');
const subqueries = plugin.spec.queries as Array<{
type: string;
spec: { name?: string };
}>;
expect(subqueries).toHaveLength(2);
expect(subqueries.map((s) => s.type)).toStrictEqual([
'builder_query',
'builder_query',
]);
expect(subqueries.map((s) => s.spec.name)).toStrictEqual(['A', 'B']);
});
it('still respects backend invariant: returns exactly one envelope for any non-empty input', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ queryName: 'A' }),
metricsBuilderQuery({ queryName: 'B' }),
metricsBuilderQuery({ queryName: 'C' }),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
});
it('wraps builder + formula as signoz/CompositeQuery (mixed content)', () => {
const formula = {
queryName: 'F1',
expression: 'A + 1',
disabled: false,
legend: '',
having: [],
orderBy: [],
};
const result = toPerses({
query: {
id: 'q-mixed',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [metricsBuilderQuery()],
queryFormulas: [formula],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(getPlugin(result).kind).toBe('signoz/CompositeQuery');
const subqueries = getPlugin(result).spec.queries as Array<{
type: string;
}>;
expect(subqueries.map((s) => s.type).sort()).toStrictEqual(
['builder_formula', 'builder_query'].sort(),
);
});
});
// ---- Phase 4: formula + trace-operator invariant guard --------------------
//
// Formulas (`A + 1`) and trace operators (`A && B`) reference builder queries
// by name. They are invalid in isolation — the wrapper must always be
// CompositeQuery alongside at least one builder query. toPerses throws on
// save-time violation; fromPerses warns on read.
describe('toPerses (Phase 4 — formula/trace-op invariant guard)', () => {
it('throws when V1 has a formula but no builder queries', () => {
const formula = {
queryName: 'F1',
expression: 'A + 1',
disabled: false,
legend: '',
having: [],
orderBy: [],
};
expect(() =>
toPerses({
query: {
id: 'q-bad-formula',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [formula],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.TIME_SERIES,
}),
).toThrow(/Formulas and trace operators reference builder queries/);
});
it('throws when V1 has a trace operator but no builder queries', () => {
const traceOperator = {
queryName: 'T1',
dataSource: DataSource.TRACES,
expression: 'A && B',
aggregations: [{ expression: 'count()' }],
functions: [],
filter: { expression: '' },
filters: { items: [], op: 'AND' },
groupBy: [],
disabled: false,
having: [],
limit: null,
stepInterval: 60,
orderBy: [],
legend: '',
} as unknown as IBuilderQuery;
expect(() =>
toPerses({
query: {
id: 'q-bad-traceop',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [traceOperator],
},
},
graphType: PANEL_TYPES.TIME_SERIES,
}),
).toThrow(/Formulas and trace operators reference builder queries/);
});
it('does not throw when builder is present alongside formula/trace-op', () => {
const formula = {
queryName: 'F1',
expression: 'A + 1',
disabled: false,
legend: '',
having: [],
orderBy: [],
};
expect(() =>
toPerses({
query: {
id: 'q-mixed-ok',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [metricsBuilderQuery()],
queryFormulas: [formula],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.TIME_SERIES,
}),
).not.toThrow();
});
});
// ---- Phase 5: PromQL + ClickHouseSQL ---------------------------------------
function promQuery(name: string, query = 'up'): Query {
return {
id: `q-prom-${name}`,
queryType: EQueryType.PROM,
clickhouse_sql: [],
promql: [{ name, query, disabled: false, legend: '' }],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
};
}
function clickhouseQuery(name: string, query = 'SELECT 1'): Query {
return {
id: `q-ch-${name}`,
queryType: EQueryType.CLICKHOUSE,
promql: [],
clickhouse_sql: [{ name, query, disabled: false, legend: '' }],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
};
}
describe('toPerses (Phase 5 — PromQL)', () => {
it('wraps a single PromQL query as bare signoz/PromQLQuery', () => {
const result = toPerses({
query: promQuery('P1'),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
expect(result[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
expect(getPlugin(result).kind).toBe('signoz/PromQLQuery');
expect(getPlugin(result).spec).toMatchObject({ name: 'P1', query: 'up' });
});
it('wraps multiple PromQL queries as signoz/CompositeQuery', () => {
const result = toPerses({
query: {
id: 'q-prom-multi',
queryType: EQueryType.PROM,
clickhouse_sql: [],
promql: [
{ name: 'P1', query: 'up', disabled: false, legend: '' },
{ name: 'P2', query: 'rate(x[1m])', disabled: false, legend: '' },
],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
},
graphType: PANEL_TYPES.TIME_SERIES,
});
const plugin = getPlugin(result);
expect(plugin.kind).toBe('signoz/CompositeQuery');
const subqueries = plugin.spec.queries as Array<{ type: string }>;
expect(subqueries.map((s) => s.type)).toStrictEqual(['promql', 'promql']);
});
});
describe('toPerses (Phase 5 — ClickHouseSQL)', () => {
it('wraps a single ClickHouse query as bare signoz/ClickHouseSQL', () => {
const result = toPerses({
query: clickhouseQuery('C1', 'SELECT count(*)'),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
expect(getPlugin(result).kind).toBe('signoz/ClickHouseSQL');
expect(getPlugin(result).spec).toMatchObject({
name: 'C1',
query: 'SELECT count(*)',
});
});
it('wraps multiple ClickHouse queries as signoz/CompositeQuery', () => {
const result = toPerses({
query: {
id: 'q-ch-multi',
queryType: EQueryType.CLICKHOUSE,
promql: [],
clickhouse_sql: [
{ name: 'C1', query: 'SELECT 1', disabled: false, legend: '' },
{ name: 'C2', query: 'SELECT 2', disabled: false, legend: '' },
],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
},
graphType: PANEL_TYPES.TIME_SERIES,
});
const plugin = getPlugin(result);
expect(plugin.kind).toBe('signoz/CompositeQuery');
const subqueries = plugin.spec.queries as Array<{ type: string }>;
expect(subqueries.map((s) => s.type)).toStrictEqual([
'clickhouse_sql',
'clickhouse_sql',
]);
});
});
// ---- Phase 6: edge cases ---------------------------------------------------
describe('toPerses (Phase 6 — edge cases)', () => {
it('returns [] when queryType is unrecognized', () => {
const result = toPerses({
query: {
id: 'q-bogus',
queryType: 'WHATEVER' as EQueryType,
promql: [{ name: 'P1', query: 'up', disabled: false, legend: '' }],
clickhouse_sql: [],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
} as unknown as Query,
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toStrictEqual([]);
});
});

View File

@@ -1,276 +0,0 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type {
IBuilderFormula,
IBuilderQuery,
IBuilderTraceOperator,
IClickHouseQuery,
IPromQLQuery,
OrderByPayload,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import type {
BuilderQuery,
ClickHouseQuery,
CompositeQuery,
GroupByKey,
OrderBy,
PromQuery,
QueryBuilderFormula,
QueryEnvelope,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { makeEmptyV1Query, signalToDataSource } from './shared';
// IPromQLQuery and IClickHouseQuery are structurally identical
// ({name, query, disabled, legend}). Both v5 PromQuery and ClickHouseQuery
// carry the same four required-on-wire fields. One helper covers both.
type NamedQueryV5 = Pick<PromQuery, 'name' | 'query' | 'disabled' | 'legend'>;
/**
* Converts the perses on-wire envelope to the V1 in-memory `Query` shape that
* QueryBuilderContext consumes.
*
* Always returns a fully-inflated `{queryData, queryFormulas, queryTraceOperator}`
* shape so the QB UI never branches on "is the builder hydrated yet."
*
* Phase 5: all six perses plugin kinds (BuilderQuery, CompositeQuery, Formula,
* TraceOperator, PromQLQuery, ClickHouseSQL) handled both at top level and
* inside CompositeQuery. Composite queryType resolution: all-promql → PROM,
* all-clickhouse → CLICKHOUSE, otherwise QUERY_BUILDER.
*/
export function fromPerses(persesQueries: DashboardtypesQueryDTO[]): Query {
// Backend invariant: panel.queries.length === 1. We trust that here and
// only consume the first entry — extras (a malformed payload) are ignored.
const plugin = persesQueries[0]?.spec?.plugin;
const result = makeEmptyV1Query();
// Defensive: skip if plugin is missing or its spec is absent. v5BuilderToV1
// and friends assume a non-undefined spec, so a missing one would otherwise
// crash inside a helper.
if (!plugin?.spec) {
return result;
}
switch (plugin.kind) {
case 'signoz/BuilderQuery':
result.builder.queryData.push(v5BuilderToV1(plugin.spec as BuilderQuery));
break;
// Top-level Formula and TraceOperator are invalid — they reference
// builder queries by name and can only appear *inside* a CompositeQuery
// that also contains the referenced builder query. Defensive read: warn
// and drop, don't crash dashboard load.
case 'signoz/Formula':
case 'signoz/TraceOperator':
// eslint-disable-next-line no-console
console.warn(
`fromPerses: top-level ${plugin.kind} is invalid (formulas and ` +
'trace operators must travel inside a CompositeQuery alongside ' +
'the builder query they reference). Dropping.',
);
break;
case 'signoz/PromQLQuery':
result.promql.push(v5NamedQueryToV1(plugin.spec as PromQuery));
result.queryType = EQueryType.PROM;
break;
case 'signoz/ClickHouseSQL':
result.clickhouse_sql.push(v5NamedQueryToV1(plugin.spec as ClickHouseQuery));
result.queryType = EQueryType.CLICKHOUSE;
break;
case 'signoz/CompositeQuery': {
const composite = plugin.spec as CompositeQuery;
const subqueries = composite.queries ?? [];
subqueries.forEach((sub) => dispatchSubquery(sub, result));
warnIfFormulaOrTraceOperatorWithoutBuilder(subqueries);
result.queryType = resolveCompositeQueryType(subqueries);
break;
}
default:
break;
}
return result;
}
// Mirrors the toPerses save-time invariant: formulas and trace operators
// inside a CompositeQuery must be accompanied by at least one builder_query
// subquery. On read we warn rather than throw — existing (legacy or manually-
// edited) dashboards keep loading; the diagnostic surfaces the bad state.
function warnIfFormulaOrTraceOperatorWithoutBuilder(
subqueries: QueryEnvelope[],
): void {
const hasBuilder = subqueries.some((s) => s.type === 'builder_query');
if (hasBuilder) {
return;
}
const hasFormulaOrOp = subqueries.some(
(s) => s.type === 'builder_formula' || s.type === 'builder_trace_operator',
);
if (hasFormulaOrOp) {
// eslint-disable-next-line no-console
console.warn(
'fromPerses: CompositeQuery contains formulas/trace-operators but no ' +
'builder_query subquery to reference. The saved panel is in an ' +
'invalid state.',
);
}
}
// Composite queryType resolution: all-promql → PROM, all-clickhouse →
// CLICKHOUSE, otherwise QUERY_BUILDER. V1's queryType is a single value
// (the QB UI picks which sub-list is "active") so mixed-type composites
// default to QUERY_BUILDER and the QB shows the builder tab.
function resolveCompositeQueryType(subqueries: QueryEnvelope[]): EQueryType {
if (subqueries.length === 0) {
return EQueryType.QUERY_BUILDER;
}
const types = new Set(subqueries.map((s) => s.type));
if (types.size === 1 && types.has('promql')) {
return EQueryType.PROM;
}
if (types.size === 1 && types.has('clickhouse_sql')) {
return EQueryType.CLICKHOUSE;
}
return EQueryType.QUERY_BUILDER;
}
// Distributes a CompositeQuery subquery into the right V1 bucket on `result`,
// based on its v5 envelope `type`. Each phase adds another branch.
function dispatchSubquery(sub: QueryEnvelope, result: Query): void {
// Defensive: a malformed subquery without a spec is skipped — converting an
// undefined spec would crash inside a helper. The well-formed siblings in
// the same composite still flow through.
if (!sub.spec) {
return;
}
switch (sub.type) {
case 'builder_query':
result.builder.queryData.push(v5BuilderToV1(sub.spec as BuilderQuery));
break;
case 'builder_formula':
result.builder.queryFormulas.push(
v5FormulaToV1(sub.spec as QueryBuilderFormula & { disabled?: boolean }),
);
break;
case 'builder_trace_operator':
result.builder.queryTraceOperator.push(
v5TraceOperatorToV1(sub.spec as BuilderQuery),
);
break;
case 'promql':
result.promql.push(v5NamedQueryToV1(sub.spec as PromQuery));
break;
case 'clickhouse_sql':
result.clickhouse_sql.push(v5NamedQueryToV1(sub.spec as ClickHouseQuery));
break;
default:
break;
}
}
// Maps a v5 BuilderQuery (the union of Log/Metric/Trace/Meter variants) back to
// the V1 in-memory IBuilderQuery shape. Fields the v5 spec doesn't carry come
// from the signal-specific default in `initialQueryBuilderFormValuesMap`.
function v5BuilderToV1(spec: BuilderQuery): IBuilderQuery {
const dataSource = signalToDataSource(spec.signal);
const base = initialQueryBuilderFormValuesMap[dataSource];
const name = spec.name ?? base.queryName;
return {
...base,
queryName: name,
expression: name,
dataSource,
aggregations: spec.aggregations ?? base.aggregations,
filter: spec.filter ?? { expression: '' },
// V1's `filters` is the legacy V4 tag-filter input. The v5 spec doesn't
// carry it; the QB UI reconstructs items from `filter.expression` when
// parsing the saved query.
filters: { items: [], op: 'AND' },
groupBy: (spec.groupBy ?? []).map(v5GroupByToV1),
orderBy: (spec.order ?? []).map(v5OrderByToV1),
// v5 Step = string | number; V1 stepInterval = number | null. Strings
// like "30s" can't be coerced losslessly here — drop to null and let
// the UI re-derive.
stepInterval:
typeof spec.stepInterval === 'number' ? spec.stepInterval : null,
limit: spec.limit ?? null,
legend: spec.legend ?? '',
disabled: spec.disabled ?? false,
having: spec.having ?? [],
functions: spec.functions ?? [],
selectColumns: spec.selectFields,
offset: spec.offset,
// Only the MeterBuilderQuery variant has `source`; the discriminator
// check keeps TS happy and produces `''` for non-meter variants.
source: 'source' in spec ? spec.source : '',
};
}
// v5 GroupByKey uses the v5 TelemetryFieldKey field names. V1 BaseAutocompleteData
// uses the legacy {key, dataType, type} names.
function v5GroupByToV1(g: GroupByKey): BaseAutocompleteData {
return {
key: g.name,
dataType: g.fieldDataType as BaseAutocompleteData['dataType'],
type: (g.fieldContext as BaseAutocompleteData['type']) ?? '',
id: '',
};
}
// v5 OrderBy uses {key:{name}, direction}; V1 OrderByPayload uses {columnName, order}.
function v5OrderByToV1(o: OrderBy): OrderByPayload {
return {
columnName: o.key?.name ?? '',
order: o.direction,
};
}
// v5 QueryBuilderFormula: {name, expression, functions?, order?, limit?, having?, legend?}.
// The on-wire shape also carries `disabled` (emitted by prepareQueryRangePayloadV5)
// even though the local v5 interface omits it — the intersection on the
// parameter type makes this explicit at the helper signature.
// V1 IBuilderFormula uses {queryName, expression, disabled, legend, limit, having[], orderBy}.
function v5FormulaToV1(
spec: QueryBuilderFormula & { disabled?: boolean },
): IBuilderFormula {
return {
queryName: spec.name,
expression: spec.expression,
disabled: spec.disabled ?? false,
legend: spec.legend ?? '',
limit: spec.limit,
// v5 having is {expression: string}; V1 having on formula is Having[].
// They're not structurally compatible — drop to empty array.
having: [],
stepInterval: undefined,
orderBy: (spec.order ?? []).map(v5OrderByToV1),
};
}
// A v5 trace operator spec is structurally a BuilderQuery (BaseBuilderQuery
// + signal-specific aggregations) carrying the trace-operator `expression`
// field. V1 IBuilderTraceOperator is an alias for IBuilderQuery, so we delegate
// the bulk of the mapping to v5BuilderToV1 and override `expression`.
function v5TraceOperatorToV1(spec: BuilderQuery): IBuilderTraceOperator {
const base = v5BuilderToV1(spec);
return {
...base,
expression: spec.expression ?? base.expression,
};
}
// v5 PromQuery / ClickHouseQuery carry an identical {name, query, disabled?,
// legend?} core; V1 IPromQLQuery and IClickHouseQuery are also structurally
// identical {name, query, disabled, legend}. The intersection return type lets
// callers push the result into either V1 list.
function v5NamedQueryToV1(spec: NamedQueryV5): IPromQLQuery & IClickHouseQuery {
return {
name: spec.name,
query: spec.query,
disabled: spec.disabled ?? false,
legend: spec.legend ?? '',
};
}

View File

@@ -1,111 +0,0 @@
import {
DashboardtypesQueryDTO,
DashboardtypesQueryPluginDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
// Outer envelope `kind` values used by the perses dashboard format. The Go
// backend defines `Query.Kind` as a free-form string, but the only values
// observed in `pkg/types/dashboardtypes/testdata` are these two.
export type PersesOuterKind = 'TimeSeriesQuery' | 'LogQuery';
// LIST panels (rows-oriented data — logs and traces alike) use LogQuery.
// Every other panel type uses TimeSeriesQuery.
export function deriveOuterKind(
graphType: PANEL_TYPES,
): Querybuildertypesv5RequestTypeDTO {
return graphType === PANEL_TYPES.LIST
? Querybuildertypesv5RequestTypeDTO.raw
: Querybuildertypesv5RequestTypeDTO.time_series;
}
// v5 builder query carries `signal` as a literal union; V1 in-memory uses the
// DataSource enum. These two helpers bridge the two sides.
export type V5Signal = 'logs' | 'metrics' | 'traces';
export function signalToDataSource(signal: V5Signal): DataSource {
if (signal === 'logs') {
return DataSource.LOGS;
}
if (signal === 'traces') {
return DataSource.TRACES;
}
return DataSource.METRICS;
}
export function dataSourceToSignal(dataSource: DataSource): V5Signal {
if (dataSource === DataSource.LOGS) {
return 'logs';
}
if (dataSource === DataSource.TRACES) {
return 'traces';
}
return 'metrics';
}
// fromPerses always returns this fully-inflated empty composite shape so the
// QB UI never has to branch on "is the builder hydrated yet."
export function makeEmptyV1Query(): Query {
return {
id: '',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
};
}
// Inner plugin kinds that wrap a single v5 envelope spec (no further nesting).
// CompositeQuery is excluded — it has its own wrapper (`wrapComposite`).
export type BarePluginKind =
| 'signoz/BuilderQuery'
| 'signoz/Formula'
| 'signoz/TraceOperator'
| 'signoz/PromQLQuery'
| 'signoz/ClickHouseSQL';
// Packages a single v5 envelope spec into the perses outer DTO with the
// specified inner plugin kind. The one cast per call bridges the structurally-
// identical-but-nominally-distinct local v5 / generated perses type pair.
export function wrapBare(
outerKind: Querybuildertypesv5RequestTypeDTO,
pluginKind: BarePluginKind,
envelope: QueryEnvelope,
): DashboardtypesQueryDTO {
return {
kind: outerKind,
spec: {
plugin: {
kind: pluginKind,
spec: envelope.spec,
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}
// Packages a list of v5 envelopes into a perses `signoz/CompositeQuery` DTO.
// Used when V1 has multi-query, formula, or trace-operator content — anything
// the bare wrapper kinds can't represent.
export function wrapComposite(
outerKind: Querybuildertypesv5RequestTypeDTO,
envelopes: QueryEnvelope[],
): DashboardtypesQueryDTO {
return {
kind: outerKind,
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: { queries: envelopes },
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}

View File

@@ -1,135 +0,0 @@
import type {
DashboardtypesQueryDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { prepareQueryRangePayloadV5 } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { QueryEnvelope, QueryType } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import {
type BarePluginKind,
deriveOuterKind,
wrapBare,
wrapComposite,
} from './shared';
// QUERY_BUILDER single-envelope dispatch table: when the v5 forward conversion
// emits exactly one envelope, the envelope's `type` determines which bare
// perses plugin kind wraps it. Only builder_query bare-wraps — formulas and
// trace operators reference builder queries by name (e.g. "A + 1", "A && B")
// and can never exist alone, so they always travel inside a CompositeQuery
// alongside the builder query they reference.
const QUERY_BUILDER_BARE_KIND: Partial<Record<QueryType, BarePluginKind>> = {
builder_query: 'signoz/BuilderQuery',
};
export interface ToPersesArgs {
query: Query;
graphType: PANEL_TYPES;
}
/**
* Converts a V1 in-memory `Query` to the perses on-wire envelope used by V2
* dashboards.
*
* Returns `[]` for genuinely-empty input, otherwise an array of length exactly
* one (the perses backend invariant: `panel.queries.length === 1`).
*
* Phase 5: all six perses plugin kinds — BuilderQuery, Formula, TraceOperator,
* CompositeQuery, PromQLQuery, ClickHouseSQL. PROM and CLICKHOUSE queryTypes
* collapse to their bare envelopes on single-query input, composite otherwise.
*/
export function toPerses({
query,
graphType,
}: ToPersesArgs): DashboardtypesQueryDTO[] {
if (isEmptyQuery(query)) {
return [];
}
const { queryPayload } = prepareQueryRangePayloadV5({
query,
graphType,
selectedTime: 'GLOBAL_TIME',
});
const envelopes = queryPayload.compositeQuery.queries ?? [];
if (envelopes.length === 0) {
return [];
}
const outerKind = deriveOuterKind(graphType);
switch (query.queryType) {
case EQueryType.QUERY_BUILDER:
assertFormulaAndTraceOperatorReferenceBuilder(query);
return [buildQueryBuilderEnvelope(envelopes, outerKind)];
case EQueryType.PROM:
return [buildBareOrComposite(envelopes, outerKind, 'signoz/PromQLQuery')];
case EQueryType.CLICKHOUSE:
return [buildBareOrComposite(envelopes, outerKind, 'signoz/ClickHouseSQL')];
default:
return [];
}
}
// Semantic invariant: formulas (`A + 1`) and trace operators (`A && B`)
// reference builder queries by name. They are only meaningful when at least
// one builder query is present in the same panel. Throw on save-time
// violation so corrupt state can't be persisted; reads are handled defensively
// in fromPerses.
function assertFormulaAndTraceOperatorReferenceBuilder(query: Query): void {
const builderCount = query.builder?.queryData?.length ?? 0;
const formulaCount = query.builder?.queryFormulas?.length ?? 0;
const traceOperatorCount = query.builder?.queryTraceOperator?.length ?? 0;
if (builderCount > 0) {
return;
}
if (formulaCount === 0 && traceOperatorCount === 0) {
return;
}
throw new Error(
'toPerses: cannot serialize a query with ' +
`${formulaCount} formula(s) and ${traceOperatorCount} trace operator(s) ` +
'but no builder queries. Formulas and trace operators reference ' +
'builder queries by name and cannot exist alone.',
);
}
// QUERY_BUILDER dispatch: a single envelope of a recognized type collapses to
// its bare wrapper; anything else (multi-envelope, mixed types) becomes a
// CompositeQuery containing all envelopes.
function buildQueryBuilderEnvelope(
envelopes: QueryEnvelope[],
outerKind: Querybuildertypesv5RequestTypeDTO,
): DashboardtypesQueryDTO {
if (envelopes.length === 1) {
const bareKind = QUERY_BUILDER_BARE_KIND[envelopes[0].type];
if (bareKind) {
return wrapBare(outerKind, bareKind, envelopes[0]);
}
}
return wrapComposite(outerKind, envelopes);
}
// PROM / CLICKHOUSE dispatch: single envelope → bare; multi → composite.
function buildBareOrComposite(
envelopes: QueryEnvelope[],
outerKind: Querybuildertypesv5RequestTypeDTO,
bareKind: BarePluginKind,
): DashboardtypesQueryDTO {
return envelopes.length === 1
? wrapBare(outerKind, bareKind, envelopes[0])
: wrapComposite(outerKind, envelopes);
}
function isEmptyQuery(query: Query): boolean {
const hasBuilder =
(query.builder?.queryData?.length ?? 0) > 0 ||
(query.builder?.queryFormulas?.length ?? 0) > 0 ||
(query.builder?.queryTraceOperator?.length ?? 0) > 0;
const hasPromql = (query.promql?.length ?? 0) > 0;
const hasClickhouse = (query.clickhouse_sql?.length ?? 0) > 0;
return !hasBuilder && !hasPromql && !hasClickhouse;
}

View File

@@ -1,13 +1,3 @@
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardsListPageV2 from 'pages/DashboardsListPageV2';
import DashboardsListPage from './DashboardsListPage';
function DashboardsListPageEntry(): JSX.Element {
const isV2 = useIsDashboardV2();
if (isV2) {
return <DashboardsListPageV2 />;
}
return <DashboardsListPage />;
}
export default DashboardsListPageEntry;
export default DashboardsListPage;

View File

@@ -27,9 +27,9 @@
color: white;
}
/* Shared trigger button for the sort + configure-group icons in the right
actions cluster. Provides a square hover/active background so users know
which icon they are targeting. */
// Shared trigger button for the sort + configure-group icons in the right
// actions cluster. Provides a square hover/active background so users know
// which icon they're targeting.
.iconTrigger {
display: inline-flex;
align-items: center;

View File

@@ -1,5 +1,5 @@
/* Shared building blocks for the dashboards-list view states. */
/* Composed via CSS-modules composes: from each state's own SCSS. */
// Shared building blocks for the dashboards-list view states.
// Composed via CSS-modules `composes:` from each state's own SCSS.
.cardWrapper {
display: flex;

View File

@@ -1,8 +1,8 @@
import dayjs from 'dayjs';
import { isEmpty } from 'lodash-es';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesGettableDashboardWithPinDTO } from 'api/generated/services/sigNoz.schemas';
export type DashboardListItem = DashboardtypesGettableDashboardV2DTO;
export type DashboardListItem = DashboardtypesGettableDashboardWithPinDTO;
export const tagsToStrings = (
tags: { key: string; value: string }[] | null | undefined,

View File

@@ -48,7 +48,9 @@
"node_modules",
"src/parser/*.ts",
"src/parser/TraceOperatorParser/*.ts",
"orval.config.ts"
"orval.config.ts",
"src/pages/DashboardsListPageV2/**/*",
"src/pages/DashboardPageV2/**/*"
],
"include": [
"./src",

View File

@@ -10,25 +10,6 @@ import (
)
func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
if err := router.Handle("/api/v3/traces/{traceID}/waterfall", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfall),
handler.OpenAPIDef{
ID: "GetWaterfall",
Tags: []string{"tracedetail"},
Summary: "Get waterfall view for a trace",
Description: "Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination",
Request: new(spantypes.PostableWaterfall),
RequestContentType: "application/json",
Response: new(spantypes.GettableWaterfallTrace),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v4/traces/{traceID}/waterfall", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfallV4),
handler.OpenAPIDef{
@@ -67,5 +48,24 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v3/traces/{traceID}/flamegraph", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetFlamegraph),
handler.OpenAPIDef{
ID: "GetFlamegraph",
Tags: []string{"tracedetail"},
Summary: "Get flamegraph view for a trace",
Description: "Returns the flamegraph view of spans for a given trace ID.",
Request: new(spantypes.PostableFlamegraph),
RequestContentType: "application/json",
Response: new(spantypes.GettableFlamegraphTrace),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"log/slog"
"net/url"
"path"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
@@ -14,6 +15,7 @@ import (
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -29,12 +31,13 @@ var scopes []string = []string{"email", "profile"}
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
store authtypes.AuthNStore
settings factory.ScopedProviderSettings
httpClient *client.Client
store authtypes.AuthNStore
settings factory.ScopedProviderSettings
httpClient *client.Client
globalConfig global.Config
}
func New(ctx context.Context, store authtypes.AuthNStore, providerSettings factory.ProviderSettings) (*AuthN, error) {
func New(ctx context.Context, store authtypes.AuthNStore, providerSettings factory.ProviderSettings, globalConfig global.Config) (*AuthN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/authn/callbackauthn/googlecallbackauthn")
httpClient, err := client.New(settings.Logger(), providerSettings.TracerProvider, providerSettings.MeterProvider)
@@ -43,9 +46,10 @@ func New(ctx context.Context, store authtypes.AuthNStore, providerSettings facto
}
return &AuthN{
store: store,
settings: settings,
httpClient: httpClient,
store: store,
settings: settings,
httpClient: httpClient,
globalConfig: globalConfig,
}, nil
}
@@ -178,7 +182,7 @@ func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain,
RedirectURL: (&url.URL{
Scheme: siteURL.Scheme,
Host: siteURL.Host,
Path: redirectPath,
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
}).String(),
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
<svg id="uuid-c6c3f75e-5369-448e-b895-3f99fb11bebe" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M7.456.608c-.902-.411-1.909-.559-2.898-.417.053.041.086.107.082.179l-.082,1.405c.879-.183,1.827-.043,2.65.469.338.21.639.474.892.781,0,0,.024.027.061.069.091.104.26.299.334.402.006.031-.004.062-.026.084-.001.001-.002.002-.003.004l-.052.048-.765.681c-.039.035-.042.095-.007.134.017.019.04.03.065.031l1.107.065,1.402.082c.072.004.138-.029.179-.083.025-.033.041-.073.044-.117l.147-2.513c.003-.052-.037-.097-.089-.1-.025-.001-.049.007-.068.024l-.764.682v.003c-.106-.164-.22-.319-.34-.467-.516-.636-1.159-1.122-1.869-1.445Z" fill="#0078d4"/><path d="M4.441.147L1.932,0c-.052-.003-.097.037-.1.09-.001.025.007.049.024.068l.681.766h.003c-.159.104-.311.214-.455.331-.629.509-1.111,1.143-1.436,1.842-.424.913-.578,1.937-.434,2.942.041-.053.107-.086.179-.082l1.402.082c-.183-.881-.043-1.83.468-2.655.209-.338.473-.64.78-.893,0,0,.029-.026.072-.064.104-.092.297-.259.399-.332.031-.006.062.004.084.026.001.001.002.002.003.003l.048.052.679.766c.035.039.095.042.134.008.019-.017.03-.04.031-.065l.064-1.109.082-1.405c.004-.072-.029-.138-.082-.179-.033-.025-.073-.041-.117-.044Z" fill="#46a0de"/><path d="M10.411,5.611c.025-.363.013-.73-.039-1.095-.041.053-.107.086-.179.082l-1.402-.082c.038.186.062.374.071.564l1.55.53Z" fill="#155ea1"/><path d="M3.576,9.604l.271-.049,1.845-.343c-.095-.084-.155-.206-.155-.34v-.025c-.733.051-1.487-.119-2.159-.536-.338-.21-.639-.474-.892-.781,0,0-.024-.027-.061-.069-.091-.104-.26-.299-.334-.402-.006-.031.004-.062.026-.084.001-.001.002-.002.003-.004l.052-.048.765-.681c.039-.035.042-.095.007-.134-.017-.019-.04-.03-.065-.031l-1.107-.065-1.402-.082c-.072-.004-.138.029-.179.083-.025.033-.041.073-.044.117L0,8.645c-.003.052.037.097.089.1.025.001.049-.007.068-.024l.764-.682v-.003c.106.164.22.319.34.467.516.636,1.159,1.122,1.869,1.445.026.012.053.021.08.033.029-.188.173-.342.365-.376Z" fill="#8dc8e8"/><g><polygon points="8.241 5.343 5.968 5.765 5.968 8.87 8.241 9.355 10.522 8.44 10.522 6.123 8.241 5.343" fill="#8661c5"/><path d="M8.328,9.307l2.082-.844c.048-.019.084-.061.095-.111v-2.102c-.004-.064-.044-.119-.103-.143l-2.106-.716h-.095l-2.066.382c-.066.017-.114.075-.119.143v2.81c-.002.073.048.136.119.151l2.09.438c.035.004.07.002.103-.008Z" fill="none"/><path d="M5.968,5.765v3.105l2.297.486v-3.98l-2.297.39ZM6.938,8.631l-.644-.127v-2.388l.644-.103v2.619ZM7.939,8.814l-.739-.119v-2.73l.739-.127v2.977Z" fill="#56407f"/><polygon points="13.16 5.383 10.887 5.805 10.887 8.909 13.16 9.395 15.433 8.471 15.433 6.163 13.16 5.383" fill="#8661c5"/><path d="M10.887,5.805v3.105l2.281.486v-3.98l-2.281.39ZM11.849,8.67l-.644-.127v-2.388l.644-.103v2.619ZM12.85,8.854l-.739-.119v-2.73l.739-.135v2.985Z" fill="#56407f"/><polygon points="5.912 9.626 3.639 10.048 3.639 13.152 5.912 13.638 8.193 12.722 8.193 10.406 5.912 9.626" fill="#8661c5"/><path d="M3.632,10.048v3.081l2.297.486v-3.98l-2.297.414ZM4.593,12.921l-.644-.135v-2.388l.644-.111v2.635ZM5.602,13.128l-.739-.119v-2.762l.739-.127v3.009Z" fill="#56407f"/><polygon points="10.816 9.594 8.543 10.016 8.543 13.12 10.816 13.614 13.089 12.69 13.089 10.374 10.816 9.594" fill="#8661c5"/><path d="M8.543,10.016v3.112l2.289.486v-3.98l-2.289.382ZM9.504,12.889l-.644-.135v-2.388l.644-.111v2.635ZM10.506,13.065l-.739-.119v-2.73l.739-.127v2.977Z" fill="#56407f"/><polygon points="15.719 9.634 13.446 10.056 13.446 13.16 15.719 13.646 18 12.73 18 10.414 15.719 9.634" fill="#8661c5"/><path d="M13.446,10.056v3.073l2.297.486v-3.98l-2.297.422ZM14.416,12.929l-.644-.135v-2.388l.644-.111v2.635ZM15.417,13.104l-.739-.119v-2.73l.739-.127v2.977Z" fill="#56407f"/><polygon points="8.185 13.956 5.912 14.37 5.912 17.475 8.185 17.968 10.466 17.045 10.466 14.736 8.185 13.956" fill="#8661c5"/><path d="M8.273,17.904l2.074-.796c.06-.021.099-.08.095-.143v-2.07c.012-.076-.031-.149-.103-.175l-2.098-.716c-.031-.012-.065-.012-.095,0l-2.066.374c-.074.012-.128.076-.127.151v2.818c-.002.073.048.136.119.151l2.09.406c.036.012.075.012.111,0Z" fill="none"/><path d="M5.912,14.37v3.105l2.297.494v-4.044l-2.297.446ZM6.882,17.244l-.644-.135v-2.388l.644-.111v2.635ZM7.883,17.427l-.739-.119v-2.738l.739-.127v2.985Z" fill="#56407f"/><polygon points="13.097 13.988 10.824 14.41 10.824 17.514 13.097 18 15.377 17.085 15.377 14.768 13.097 13.988" fill="#8661c5"/><path d="M10.824,14.41v3.105l2.297.486v-3.98l-2.297.39ZM11.793,17.284l-.644-.135v-2.388l.644-.111v2.635ZM12.795,17.459l-.739-.119v-2.73l.739-.127v2.977Z" fill="#56407f"/></g></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,293 @@
{
"id": "aks",
"title": "Azure Kubernetes Service (AKS)",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_kube_pod_status_ready_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_pod_status_ready_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_pod_status_phase_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_pod_status_phase_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_condition_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_condition_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_cpu_usage_millicores_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_cpu_usage_millicores_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_cpu_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_cpu_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_disk_usage_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_disk_usage_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_disk_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_disk_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_rss_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_rss_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_rss_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_rss_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_working_set_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_working_set_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_working_set_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_memory_working_set_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_network_in_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_network_in_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_network_out_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_node_network_out_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_current_inflight_requests_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_current_inflight_requests_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_cpu_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_cpu_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_memory_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_apiserver_memory_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_cpu_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_cpu_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_database_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_database_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_memory_usage_percentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_etcd_memory_usage_percentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_allocatable_cpu_cores_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_allocatable_cpu_cores_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_allocatable_memory_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_kube_node_status_allocatable_memory_bytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.ContainerService",
"resourceType": "managedClusters",
"metrics": {},
"logs": {
"categoryGroups": ["allLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Azure Kubernetes Service (AKS) Overview",
"description": "Overview of Azure Kubernetes Service (AKS) metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,5 @@
### Monitor Azure Kubernetes Service (AKS) with SigNoz
Collect key AKS metrics and view them with an out of the box dashboard.
Note: This integration is only for AKS with resource type `Microsoft.ContainerService/managedClusters`.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="b27f1ad0-7d11-4247-9da3-91bce6211f32" x1="8.798" y1="8.703" x2="14.683" y2="8.703" gradientUnits="userSpaceOnUse"><stop offset="0.001" stop-color="#773adc"/><stop offset="1" stop-color="#552f99"/></linearGradient><linearGradient id="b2f92112-4ca9-4b17-a019-c9f26c1a4a8f" x1="5.764" y1="3.777" x2="5.764" y2="13.78" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a67af4"/><stop offset="0.999" stop-color="#773adc"/></linearGradient></defs><g id="b8a0486a-5501-4d92-b540-a766c4b3b548"><g><g><g><path d="M16.932,11.578a8.448,8.448,0,0,1-7.95,5.59,8.15,8.15,0,0,1-2.33-.33,2.133,2.133,0,0,0,.18-.83c.01,0,.03.01.04.01a7.422,7.422,0,0,0,2.11.3,7.646,7.646,0,0,0,6.85-4.28l.01-.01Z" fill="#32bedd"/><path d="M3.582,14.068a2.025,2.025,0,0,0-.64.56,8.6,8.6,0,0,1-1.67-2.44l1.04.23v.26a.6.6,0,0,0,.47.59l.14.03a6.136,6.136,0,0,0,.62.73Z" fill="#32bedd"/><path d="M12.352.958a2.28,2.28,0,0,0-.27.81c-.02-.01-.05-.02-.07-.03a7.479,7.479,0,0,0-3.03-.63,7.643,7.643,0,0,0-5.9,2.8l-.29.06a.6.6,0,0,0-.48.58v.46l-1.02.19A8.454,8.454,0,0,1,8.982.268,8.6,8.6,0,0,1,12.352.958Z" fill="#32bedd"/><path d="M16.872,5.7l-1.09-.38a6.6,6.6,0,0,0-.72-1.16c-.02-.03-.04-.05-.05-.07a2.083,2.083,0,0,0,.72-.45A7.81,7.81,0,0,1,16.872,5.7Z" fill="#32bedd"/><path d="M10.072,11.908l2.54.56L8.672,14.1c-.02,0-.03.01-.05.01a.154.154,0,0,1-.15-.15V3.448a.154.154,0,0,1,.15-.15.09.09,0,0,1,.05.01l4.46,1.56-3.05.57a.565.565,0,0,0-.44.54v5.4A.537.537,0,0,0,10.072,11.908Z" fill="#fff"/><g><g id="e918f286-5032-4942-ad29-ea17e6f1cc90"><path d="M1.1,5.668l1.21-.23v6.55l-1.23-.27-.99-.22a.111.111,0,0,1-.09-.12v-5.4a.12.12,0,0,1,.09-.12Z" fill="#a67af4"/></g><g><g id="a47a99dd-4d47-4c70-8c42-c5ac274ce496"><g><path d="M10.072,11.908l2.54.56L8.672,14.1c-.02,0-.03.01-.05.01a.154.154,0,0,1-.15-.15V3.448a.154.154,0,0,1,.15-.15.09.09,0,0,1,.05.01l4.46,1.56-3.05.57a.565.565,0,0,0-.44.54v5.4A.537.537,0,0,0,10.072,11.908Z" fill="url(#b27f1ad0-7d11-4247-9da3-91bce6211f32)"/><path d="M8.586,3.3,2.878,4.378a.177.177,0,0,0-.14.175V12.68a.177.177,0,0,0,.137.174L8.581,14.1a.176.176,0,0,0,.21-.174V3.478A.175.175,0,0,0,8.619,3.3Z" fill="url(#b2f92112-4ca9-4b17-a019-c9f26c1a4a8f)"/></g></g><polygon points="5.948 4.921 5.948 12.483 7.934 12.814 7.934 4.564 5.948 4.921" fill="#b796f9" opacity="0.5"/><polygon points="3.509 5.329 3.509 11.954 5.238 12.317 5.238 5.031 3.509 5.329" fill="#b796f9" opacity="0.5"/></g></g></g><path d="M16,2.048a1.755,1.755,0,1,1-1.76-1.76A1.756,1.756,0,0,1,16,2.048Z" fill="#32bedd"/><circle cx="4.65" cy="15.973" r="1.759" fill="#32bedd"/></g><path d="M18,6.689v3.844a.222.222,0,0,1-.133.2l-.766.316-3.07,1.268-.011,0a.126.126,0,0,1-.038,0,.1.1,0,0,1-.1-.1V5.234a.1.1,0,0,1,.054-.088l0,0,.019,0a.031.031,0,0,1,.019,0,.055.055,0,0,1,.034.008l.011,0,.012,0L17.05,6.2l.8.282A.213.213,0,0,1,18,6.689Z" fill="#773adc"/><path d="M13.959,5.14l-3.8.715a.118.118,0,0,0-.093.117v5.409a.118.118,0,0,0,.091.116l3.8.831a.115.115,0,0,0,.137-.09.109.109,0,0,0,0-.026V5.256a.117.117,0,0,0-.115-.118A.082.082,0,0,0,13.959,5.14Z" fill="#a67af4"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,263 @@
{
"id": "containerapp",
"title": "Container App",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_rxbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_minimum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_total",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_minimum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_total",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_coresquotaused_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_coresquotaused_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.App",
"resourceType": "containerApps",
"metrics": {},
"logs": {
"categoryGroups": ["ContainerAppConsoleLogs", "ContainerAppSystemLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Container App Overview",
"description": "Overview of Container App metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,7 @@
### Monitor Container Apps with SigNoz
Collect key Container App metrics and view them with an out of the box dashboard.
To collect logs, you need to make sure that you have chosen "Azure Monitor" as the logging option for Container's App Environment.
Note: This integration ingests logs for only "ContainerAppConsoleLogs" and "ContainerAppSystemLogs" diagnostic settings categories.

View File

@@ -219,19 +219,7 @@ func (m *module) GetStats(ctx context.Context, orgID valuer.UUID, req *metricsex
return nil, err
}
filterWhereClause, err := m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
if err != nil {
return nil, err
}
// Single query to get stats with samples, timeseries counts in required sorting order
metricStats, total, err := m.fetchMetricsStatsWithSamples(
ctx,
req,
filterWhereClause,
false,
req.OrderBy,
)
metricStats, total, err := m.fetchMetricsStatsWithSamples(ctx, req, false)
if err != nil {
return nil, err
}
@@ -268,21 +256,16 @@ func (m *module) GetTreemap(ctx context.Context, orgID valuer.UUID, req *metrics
return nil, err
}
filterWhereClause, err := m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
if err != nil {
return nil, err
}
resp := &metricsexplorertypes.TreemapResponse{}
switch req.Mode {
case metricsexplorertypes.TreemapModeSamples:
entries, err := m.computeSamplesTreemap(ctx, req, filterWhereClause)
entries, err := m.computeSamplesTreemap(ctx, req)
if err != nil {
return nil, err
}
resp.Samples = entries
default: // TreemapModeTimeSeries
entries, err := m.computeTimeseriesTreemap(ctx, req, filterWhereClause)
entries, err := m.computeTimeseriesTreemap(ctx, req)
if err != nil {
return nil, err
}
@@ -974,15 +957,23 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
func (m *module) fetchMetricsStatsWithSamples(
ctx context.Context,
req *metricsexplorertypes.StatsRequest,
filterWhereClause *sqlbuilder.WhereClause,
normalized bool,
orderBy *qbtypes.OrderBy,
) ([]metricsexplorertypes.Stat, uint64, error) {
ctx = m.withMetricsExplorerContext(ctx, "fetchMetricsStatsWithSamples")
hasFilter := req.Filter != nil && strings.TrimSpace(req.Filter.Expression) != ""
var filterWhereClause *sqlbuilder.WhereClause
if hasFilter {
var err error
filterWhereClause, err = m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
if err != nil {
return nil, 0, err
}
}
start, end, distributedTsTable, localTsTable := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), nil)
samplesTable, _ := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil)
countExp := telemetrymetrics.CountExpressionForSamplesTable(samplesTable)
distributedSamplesTable, _ := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil)
countExp := telemetrymetrics.CountExpressionForSamplesTable(distributedSamplesTable)
// Timeseries counts per metric
tsSB := sqlbuilder.NewSelectBuilder()
@@ -1005,7 +996,7 @@ func (m *module) fetchMetricsStatsWithSamples(
"metric_name",
fmt.Sprintf("%s AS samples", countExp),
)
samplesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, samplesTable))
samplesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedSamplesTable))
samplesSB.Where(samplesSB.Between("unix_milli", req.Start, req.End))
samplesSB.Where("NOT startsWith(metric_name, 'signoz')")
@@ -1013,6 +1004,8 @@ func (m *module) fetchMetricsStatsWithSamples(
sqlbuilder.CTEQuery("__time_series_counts").As(tsSB),
}
// Narrow samples scan. With filter: fingerprint IN (per-fingerprint label preds can't fold to metric_name).
// No filter (fast path): metric_name IN — aligns with samples table's leading sort key, orders of magnitude cheaper.
if filterWhereClause != nil {
fingerprintSB := sqlbuilder.NewSelectBuilder()
fingerprintSB.Select("fingerprint")
@@ -1025,6 +1018,15 @@ func (m *module) fetchMetricsStatsWithSamples(
ctes = append(ctes, sqlbuilder.CTEQuery("__filtered_fingerprints").As(fingerprintSB))
samplesSB.Where("fingerprint IN (SELECT fingerprint FROM __filtered_fingerprints)")
} else {
metricNamesSB := sqlbuilder.NewSelectBuilder()
metricNamesSB.Select("DISTINCT metric_name")
metricNamesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTsTable))
metricNamesSB.Where(metricNamesSB.Between("unix_milli", start, end))
metricNamesSB.Where("NOT startsWith(metric_name, 'signoz')")
metricNamesSB.Where(metricNamesSB.E("__normalized", normalized))
samplesSB.Where(fmt.Sprintf("metric_name IN (%s)", samplesSB.Var(metricNamesSB)))
}
samplesSB.GroupBy("metric_name")
@@ -1041,7 +1043,7 @@ func (m *module) fetchMetricsStatsWithSamples(
finalSB.JoinWithOption(sqlbuilder.FullOuterJoin, "__sample_counts s", "ts.metric_name = s.metric_name")
finalSB.Where("(COALESCE(ts.timeseries, 0) > 0 OR COALESCE(s.samples, 0) > 0)")
orderByColumn, orderDirection, err := getStatsOrderByColumn(orderBy)
orderByColumn, orderDirection, err := getStatsOrderByColumn(req.OrderBy)
if err != nil {
return nil, 0, err
}
@@ -1085,9 +1087,19 @@ func (m *module) fetchMetricsStatsWithSamples(
return metricStats, total, nil
}
func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplorertypes.TreemapRequest, filterWhereClause *sqlbuilder.WhereClause) ([]metricsexplorertypes.TreemapEntry, error) {
func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplorertypes.TreemapRequest) ([]metricsexplorertypes.TreemapEntry, error) {
ctx = m.withMetricsExplorerContext(ctx, "computeTimeseriesTreemap")
hasFilter := req.Filter != nil && strings.TrimSpace(req.Filter.Expression) != ""
var filterWhereClause *sqlbuilder.WhereClause
if hasFilter {
var err error
filterWhereClause, err = m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
if err != nil {
return nil, err
}
}
start, end, distributedTsTable, _ := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), nil)
totalTSBuilder := sqlbuilder.NewSelectBuilder()
@@ -1151,12 +1163,22 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplo
return entries, nil
}
func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorertypes.TreemapRequest, filterWhereClause *sqlbuilder.WhereClause) ([]metricsexplorertypes.TreemapEntry, error) {
func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorertypes.TreemapRequest) ([]metricsexplorertypes.TreemapEntry, error) {
ctx = m.withMetricsExplorerContext(ctx, "computeSamplesTreemap")
hasFilter := req.Filter != nil && strings.TrimSpace(req.Filter.Expression) != ""
var filterWhereClause *sqlbuilder.WhereClause
if hasFilter {
var err error
filterWhereClause, err = m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
if err != nil {
return nil, err
}
}
start, end, distributedTsTable, localTsTable := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), nil)
samplesTable, _ := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil)
countExp := telemetrymetrics.CountExpressionForSamplesTable(samplesTable)
distributedSamplesTable, _ := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil)
countExp := telemetrymetrics.CountExpressionForSamplesTable(distributedSamplesTable)
candidateLimit := req.Limit + 50
@@ -1179,7 +1201,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
totalSamplesSB := sqlbuilder.NewSelectBuilder()
totalSamplesSB.Select(fmt.Sprintf("%s AS total_samples", countExp))
totalSamplesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, samplesTable))
totalSamplesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedSamplesTable))
totalSamplesSB.Where(totalSamplesSB.Between("unix_milli", req.Start, req.End))
sampleCountsSB := sqlbuilder.NewSelectBuilder()
@@ -1187,7 +1209,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
"metric_name",
fmt.Sprintf("%s AS samples", countExp),
)
sampleCountsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, samplesTable))
sampleCountsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedSamplesTable))
sampleCountsSB.Where(sampleCountsSB.Between("unix_milli", req.Start, req.End))
sampleCountsSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates)")

View File

@@ -0,0 +1,270 @@
package implmetricsexplorer_test
import (
"context"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/cache/cachetest"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
)
const (
// fixed, deterministic time window (range < 6h so the table selectors resolve
// to stable table names that the expected SQL strings can hard-code).
testStartMillis int64 = 1700000000000
testEndMillis int64 = 1700003600000 // +1h
statsNoFilterSQL = "WITH __time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? GROUP BY metric_name), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND metric_name IN (SELECT DISTINCT metric_name FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ?) GROUP BY metric_name) SELECT COALESCE(ts.metric_name, s.metric_name) AS metric_name, COALESCE(ts.timeseries, 0) AS timeseries, COALESCE(s.samples, 0) AS samples, COUNT(*) OVER() AS total FROM __time_series_counts ts FULL OUTER JOIN __sample_counts s ON ts.metric_name = s.metric_name WHERE (COALESCE(ts.timeseries, 0) > 0 OR COALESCE(s.samples, 0) > 0) ORDER BY samples DESC, metric_name ASC LIMIT ? OFFSET ?"
statsOrderTimeseriesSQL = "WITH __time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? GROUP BY metric_name), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND metric_name IN (SELECT DISTINCT metric_name FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ?) GROUP BY metric_name) SELECT COALESCE(ts.metric_name, s.metric_name) AS metric_name, COALESCE(ts.timeseries, 0) AS timeseries, COALESCE(s.samples, 0) AS samples, COUNT(*) OVER() AS total FROM __time_series_counts ts FULL OUTER JOIN __sample_counts s ON ts.metric_name = s.metric_name WHERE (COALESCE(ts.timeseries, 0) > 0 OR COALESCE(s.samples, 0) > 0) ORDER BY timeseries ASC, metric_name ASC LIMIT ? OFFSET ?"
statsWithFilterSQL = "WITH __time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name), __filtered_fingerprints AS (SELECT fingerprint FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? AND JSONExtractString(labels, 'host.name') = ? GROUP BY fingerprint), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND fingerprint IN (SELECT fingerprint FROM __filtered_fingerprints) GROUP BY metric_name) SELECT COALESCE(ts.metric_name, s.metric_name) AS metric_name, COALESCE(ts.timeseries, 0) AS timeseries, COALESCE(s.samples, 0) AS samples, COUNT(*) OVER() AS total FROM __time_series_counts ts FULL OUTER JOIN __sample_counts s ON ts.metric_name = s.metric_name WHERE (COALESCE(ts.timeseries, 0) > 0 OR COALESCE(s.samples, 0) > 0) ORDER BY samples DESC, metric_name ASC LIMIT ? OFFSET ?"
treemapTimeseriesNoFilterSQL = "WITH __total_time_series AS (SELECT uniq(fingerprint) AS total_time_series FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND __normalized = ?), __metric_totals AS (SELECT metric_name, uniq(fingerprint) AS total_value FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? GROUP BY metric_name) SELECT mt.metric_name, mt.total_value, CASE WHEN tts.total_time_series = 0 THEN 0 ELSE (mt.total_value * 100.0 / tts.total_time_series) END AS percentage FROM __metric_totals mt JOIN __total_time_series tts ON 1=1 ORDER BY percentage DESC LIMIT ?"
treemapTimeseriesWithFilterSQL = "WITH __total_time_series AS (SELECT uniq(fingerprint) AS total_time_series FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND __normalized = ?), __metric_totals AS (SELECT metric_name, uniq(fingerprint) AS total_value FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name) SELECT mt.metric_name, mt.total_value, CASE WHEN tts.total_time_series = 0 THEN 0 ELSE (mt.total_value * 100.0 / tts.total_time_series) END AS percentage FROM __metric_totals mt JOIN __total_time_series tts ON 1=1 ORDER BY percentage DESC LIMIT ?"
treemapSamplesNoFilterSQL = "WITH __metric_candidates AS (SELECT metric_name FROM signoz_metrics.distributed_time_series_v4 WHERE NOT startsWith(metric_name, 'signoz') AND __normalized = ? AND unix_milli BETWEEN ? AND ? GROUP BY metric_name ORDER BY uniq(fingerprint) DESC LIMIT ?), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates) GROUP BY metric_name), __total_samples AS (SELECT count(*) AS total_samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ?) SELECT mc.metric_name, COALESCE(sc.samples, 0) AS samples, CASE WHEN ts.total_samples = 0 THEN 0 ELSE (COALESCE(sc.samples, 0) * 100.0 / ts.total_samples) END AS percentage FROM __metric_candidates mc LEFT JOIN __sample_counts sc ON mc.metric_name = sc.metric_name JOIN __total_samples ts ON 1=1 ORDER BY percentage DESC LIMIT ?"
treemapSamplesWithFilterSQL = "WITH __metric_candidates AS (SELECT metric_name FROM signoz_metrics.distributed_time_series_v4 WHERE NOT startsWith(metric_name, 'signoz') AND __normalized = ? AND unix_milli BETWEEN ? AND ? AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name ORDER BY uniq(fingerprint) DESC LIMIT ?), __filtered_fingerprints AS (SELECT fingerprint FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? AND JSONExtractString(labels, 'host.name') = ? AND metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates) GROUP BY fingerprint), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates) AND fingerprint IN (SELECT fingerprint FROM __filtered_fingerprints) GROUP BY metric_name), __total_samples AS (SELECT count(*) AS total_samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ?) SELECT mc.metric_name, COALESCE(sc.samples, 0) AS samples, CASE WHEN ts.total_samples = 0 THEN 0 ELSE (COALESCE(sc.samples, 0) * 100.0 / ts.total_samples) END AS percentage FROM __metric_candidates mc LEFT JOIN __sample_counts sc ON mc.metric_name = sc.metric_name JOIN __total_samples ts ON 1=1 ORDER BY percentage DESC LIMIT ?"
)
var testOrgID = valuer.GenerateUUID()
type statsOpt func(*metricsexplorertypes.StatsRequest)
type treemapOpt func(*metricsexplorertypes.TreemapRequest)
// newTestModule builds the metricsexplorer module backed by a mocked clickhouse
// connection, a mock metadata store, and an in-memory cache.
func newTestModule(t *testing.T, matcher sqlmock.QueryMatcher) (metricsexplorer.Module, cmock.ClickConnMockCommon, *telemetrytypestest.MockMetadataStore) {
t.Helper()
ts := telemetrystoretest.New(telemetrystore.Config{}, matcher)
md := telemetrytypestest.NewMockMetadataStore()
c, err := cachetest.New(cache.Config{Provider: "memory", Memory: cache.Memory{NumCounters: 1000, MaxCost: 1 << 20}})
if err != nil {
t.Fatalf("cachetest.New: %v", err)
}
settings := instrumentationtest.New().ToProviderSettings()
mod := implmetricsexplorer.NewModule(ts, md, c, nil /*ruleStore*/, nil /*dashboardModule*/, settings, metricsexplorer.Config{})
return mod, ts.Mock(), md
}
func statsRequest(opts ...statsOpt) *metricsexplorertypes.StatsRequest {
req := &metricsexplorertypes.StatsRequest{
Start: testStartMillis,
End: testEndMillis,
Limit: 10,
}
for _, o := range opts {
o(req)
}
return req
}
func withStatsFilter(expr string) statsOpt {
return func(req *metricsexplorertypes.StatsRequest) {
req.Filter = &qbtypes.Filter{Expression: expr}
}
}
func withStatsLimit(limit int) statsOpt {
return func(req *metricsexplorertypes.StatsRequest) {
req.Limit = limit
}
}
func withStatsOrderBy(name string, dir qbtypes.OrderDirection) statsOpt {
return func(req *metricsexplorertypes.StatsRequest) {
req.OrderBy = &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: name}},
Direction: dir,
}
}
}
func treemapRequest(mode metricsexplorertypes.TreemapMode, opts ...treemapOpt) *metricsexplorertypes.TreemapRequest {
req := &metricsexplorertypes.TreemapRequest{
Start: testStartMillis,
End: testEndMillis,
Limit: 10,
Mode: mode,
}
for _, o := range opts {
o(req)
}
return req
}
func withTreemapFilter(expr string) treemapOpt {
return func(req *metricsexplorertypes.TreemapRequest) {
req.Filter = &qbtypes.Filter{Expression: expr}
}
}
// seedFilterKey registers a string attribute field so buildFilterClause can
// resolve it when parsing a filter expression that references it.
func seedFilterKey(md *telemetrytypestest.MockMetadataStore, name string) {
md.KeysMap[name] = []*telemetrytypes.TelemetryFieldKey{
{
Name: name,
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
}
// anyArgs returns n nil wildcards. cmock treats a nil expected arg as a match
// for any actual value, so this asserts only the bound-arg count, not values
// (the SQL text itself is what we verify). The count must match the query.
func anyArgs(n int) []any {
return make([]any, n)
}
func treemapEntryRows() *cmock.Rows {
return cmock.NewRows(
[]cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "total_value", Type: "UInt64"},
{Name: "percentage", Type: "Float64"},
},
[][]any{
{"metric_a", uint64(50), 50.0},
{"metric_b", uint64(30), 30.0},
},
)
}
func TestGetStats(t *testing.T) {
tests := []struct {
name string
opts []statsOpt
seedKey string
queryErr error
expectSQL string
argCount int
noQuery bool // SQL never reaches clickhouse (validation/build error)
wantCode errors.Code
}{
{name: "NoFilter_FastPathSQL", expectSQL: statsNoFilterSQL, argCount: 10},
{name: "WhitespaceFilter_FastPathSQL", opts: []statsOpt{withStatsFilter(" ")}, expectSQL: statsNoFilterSQL, argCount: 10},
{name: "WithFilter_FingerprintSQL", opts: []statsOpt{withStatsFilter("host.name = 'foo'")}, seedKey: "host.name", expectSQL: statsWithFilterSQL, argCount: 12},
{name: "OrderByTimeseriesAsc", opts: []statsOpt{withStatsOrderBy("timeseries", qbtypes.OrderDirectionAsc)}, expectSQL: statsOrderTimeseriesSQL, argCount: 10},
{name: "OrderByInvalid", opts: []statsOpt{withStatsOrderBy("nonsense", qbtypes.OrderDirectionAsc)}, noQuery: true, wantCode: errors.CodeInvalidInput},
{name: "QueryError", queryErr: assert.AnError, expectSQL: statsNoFilterSQL, argCount: 10, wantCode: errors.CodeInternal},
{name: "InvalidRequest_Limit", opts: []statsOpt{withStatsLimit(0)}, noQuery: true, wantCode: errors.CodeInvalidInput},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mod, mock, md := newTestModule(t, sqlmock.QueryMatcherRegexp)
if tc.seedKey != "" {
seedFilterKey(md, tc.seedKey)
}
if !tc.noQuery {
eq := mock.ExpectQuery(regexp.QuoteMeta(tc.expectSQL)).WithArgs(anyArgs(tc.argCount)...)
if tc.queryErr != nil {
eq.WillReturnError(tc.queryErr)
} else {
eq.WillReturnRows(cmock.NewRows(nil, nil))
}
}
_, err := mod.GetStats(context.Background(), testOrgID, statsRequest(tc.opts...))
if tc.wantCode.String() != "" {
assert.Error(t, err)
assert.Truef(t, errors.Asc(err, tc.wantCode), "want code %s, got %v", tc.wantCode, err)
return
}
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
})
}
}
func TestGetTreemap(t *testing.T) {
wantEntries := []metricsexplorertypes.TreemapEntry{
{MetricName: "metric_a", TotalValue: 50, Percentage: 50.0},
{MetricName: "metric_b", TotalValue: 30, Percentage: 30.0},
}
tests := []struct {
name string
mode metricsexplorertypes.TreemapMode
opts []treemapOpt
seedKey string
queryErr error
expectSQL string
argCount int
rows *cmock.Rows
wantSamples []metricsexplorertypes.TreemapEntry
wantTS []metricsexplorertypes.TreemapEntry
noQuery bool
wantCode errors.Code
wantErr bool
}{
{name: "TimeSeries_NoFilter_SQL", mode: metricsexplorertypes.TreemapModeTimeSeries, expectSQL: treemapTimeseriesNoFilterSQL, argCount: 7},
{name: "TimeSeries_WithFilter_SQL", mode: metricsexplorertypes.TreemapModeTimeSeries, opts: []treemapOpt{withTreemapFilter("host.name = 'foo'")}, seedKey: "host.name", expectSQL: treemapTimeseriesWithFilterSQL, argCount: 8},
{name: "TimeSeries_ScansEntries", mode: metricsexplorertypes.TreemapModeTimeSeries, expectSQL: treemapTimeseriesNoFilterSQL, argCount: 7, rows: treemapEntryRows(), wantTS: wantEntries},
{name: "Samples_NoFilter_SQL", mode: metricsexplorertypes.TreemapModeSamples, expectSQL: treemapSamplesNoFilterSQL, argCount: 9},
{name: "Samples_WithFilter_SQL", mode: metricsexplorertypes.TreemapModeSamples, opts: []treemapOpt{withTreemapFilter("host.name = 'foo'")}, seedKey: "host.name", expectSQL: treemapSamplesWithFilterSQL, argCount: 14},
{name: "Samples_ScansEntries", mode: metricsexplorertypes.TreemapModeSamples, expectSQL: treemapSamplesNoFilterSQL, argCount: 9, rows: treemapEntryRows(), wantSamples: wantEntries},
{name: "FilterBuildError", mode: metricsexplorertypes.TreemapModeTimeSeries, opts: []treemapOpt{withTreemapFilter("host.name =")}, noQuery: true, wantErr: true},
{name: "QueryError", mode: metricsexplorertypes.TreemapModeTimeSeries, queryErr: assert.AnError, expectSQL: treemapTimeseriesNoFilterSQL, argCount: 7, wantCode: errors.CodeInternal},
{name: "InvalidMode", mode: metricsexplorertypes.TreemapMode{}, noQuery: true, wantCode: errors.CodeInvalidInput},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mod, mock, md := newTestModule(t, sqlmock.QueryMatcherRegexp)
if tc.seedKey != "" {
seedFilterKey(md, tc.seedKey)
}
if !tc.noQuery {
eq := mock.ExpectQuery(regexp.QuoteMeta(tc.expectSQL)).WithArgs(anyArgs(tc.argCount)...)
switch {
case tc.queryErr != nil:
eq.WillReturnError(tc.queryErr)
case tc.rows != nil:
eq.WillReturnRows(tc.rows)
default:
eq.WillReturnRows(cmock.NewRows(nil, nil))
}
}
resp, err := mod.GetTreemap(context.Background(), testOrgID, treemapRequest(tc.mode, tc.opts...))
switch {
case tc.wantCode.String() != "":
assert.Error(t, err)
assert.Truef(t, errors.Asc(err, tc.wantCode), "want code %s, got %v", tc.wantCode, err)
return
case tc.wantErr:
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
if tc.wantTS != nil {
assert.Equal(t, tc.wantTS, resp.TimeSeries)
}
if tc.wantSamples != nil {
assert.Equal(t, tc.wantSamples, resp.Samples)
}
})
}
}

View File

@@ -4,9 +4,11 @@ import (
"context"
"net/http"
"net/url"
"path"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/session"
@@ -15,11 +17,12 @@ import (
)
type handler struct {
module session.Module
module session.Module
globalConfig global.Config
}
func NewHandler(module session.Module) session.Handler {
return &handler{module: module}
func NewHandler(module session.Module, globalConfig global.Config) session.Handler {
return &handler{module: module, globalConfig: globalConfig}
}
func (handler *handler) GetSessionContext(rw http.ResponseWriter, req *http.Request) {
@@ -158,13 +161,13 @@ func (handler *handler) DeleteSession(rw http.ResponseWriter, req *http.Request)
render.Success(rw, http.StatusNoContent, nil)
}
func (*handler) getRedirectURLFromErr(err error) string {
func (handler *handler) getRedirectURLFromErr(err error) string {
values := errors.AsURLValues(err)
values.Add("callbackauthnerr", "true")
return (&url.URL{
// When UI is being served on a prefix, we need to redirect to the login page on the prefix.
Path: "/login",
Path: path.Join(handler.globalConfig.ExternalPath(), "/login"),
RawQuery: values.Encode(),
}).String()
}

View File

@@ -6,7 +6,16 @@ import (
)
type Config struct {
Waterfall WaterfallConfig `mapstructure:"waterfall"`
Waterfall WaterfallConfig `mapstructure:"waterfall"`
Flamegraph FlamegraphConfig `mapstructure:"flamegraph"`
}
type FlamegraphConfig struct {
MaxSelectedLevels int `mapstructure:"max_selected_levels"`
MaxSpansPerLevel int `mapstructure:"max_spans_per_level"`
SamplingTopLatencySpansCount int `mapstructure:"sampling_top_latency_count"`
SamplingBucketCount int `mapstructure:"sampling_bucket_count"`
SelectAllSpansLimit uint `mapstructure:"select_all_spans_limit"`
}
type WaterfallConfig struct {
@@ -29,6 +38,13 @@ func newConfig() factory.Config {
MaxDepthToAutoExpand: 5,
MaxLimitToSelectAllSpans: 10_000,
},
Flamegraph: FlamegraphConfig{
MaxSelectedLevels: 50,
MaxSpansPerLevel: 100,
SamplingTopLatencySpansCount: 5,
SamplingBucketCount: 50,
SelectAllSpansLimit: 100_000,
},
}
}
@@ -42,5 +58,20 @@ func (c Config) Validate() error {
if c.Waterfall.MaxLimitToSelectAllSpans == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.max_limit_to_select_all_spans must be positive")
}
if c.Flamegraph.MaxSelectedLevels <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.flamegraph.max_selected_levels must be positive, got %d", c.Flamegraph.MaxSelectedLevels)
}
if c.Flamegraph.MaxSpansPerLevel <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.flamegraph.max_spans_per_level must be positive, got %d", c.Flamegraph.MaxSpansPerLevel)
}
if c.Flamegraph.SamplingTopLatencySpansCount < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.flamegraph.sampling_top_latency_count cannot be negative, got %d", c.Flamegraph.SamplingTopLatencySpansCount)
}
if c.Flamegraph.SamplingBucketCount <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.flamegraph.sampling_bucket_count must be positive, got %d", c.Flamegraph.SamplingBucketCount)
}
if c.Flamegraph.SelectAllSpansLimit == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.flamegraph.select_all_spans_limit must be positive")
}
return nil
}

View File

@@ -18,27 +18,6 @@ func NewHandler(module tracedetail.Module) tracedetail.Handler {
return &handler{module: module}
}
func (h *handler) GetWaterfall(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.PostableWaterfall)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
if err := req.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetWaterfall(r.Context(), mux.Vars(r)["traceID"], req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.PostableWaterfall)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
@@ -51,7 +30,7 @@ func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
return
}
result, err := h.module.GetWaterfallV4(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.UncollapsedSpans, req.Limit)
result, err := h.module.GetWaterfallV4(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.UncollapsedSpans)
if err != nil {
render.Error(rw, err)
return
@@ -80,3 +59,19 @@ func (h *handler) GetTraceAggregations(rw http.ResponseWriter, r *http.Request)
render.Success(rw, http.StatusOK, result)
}
func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.PostableFlamegraph)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetFlamegraph(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.SelectFields)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"go.opentelemetry.io/otel/metric"
)
@@ -33,66 +34,21 @@ func NewModule(traceStore spantypes.TraceStore, providerSettings factory.Provide
}
m.metrics.waterfallSpanLimit.Record(context.Background(), int64(cfg.Waterfall.MaxLimitToSelectAllSpans), metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed)))
m.metrics.flamegraphSpanLimit.Record(context.Background(), int64(cfg.Flamegraph.SelectAllSpansLimit), metric.WithAttributes(attrResponseType.String(attrResponseTypeSampled)))
return m
}
func (m *module) GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error) {
waterfallTrace, err := m.getTraceData(ctx, traceID)
if err != nil {
return nil, err
}
selectedSpans, uncollapsedSpans, selectedAllSpans := waterfallTrace.GetWaterfallSpans(
req.UncollapsedSpans,
req.SelectedSpanID,
min(req.Limit, m.config.Waterfall.MaxLimitToSelectAllSpans),
m.config.Waterfall.SpanPageSize,
m.config.Waterfall.MaxDepthToAutoExpand,
)
aggregationResults := make([]spantypes.SpanAggregationResult, 0, len(req.Aggregations))
for _, a := range req.Aggregations {
aggregationResults = append(aggregationResults, waterfallTrace.GetSpanAggregation(a.Aggregation, a.Field))
}
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, uncollapsedSpans, selectedAllSpans, aggregationResults), nil
}
// getTraceData fetches all spans for a trace and builds the WaterfallTrace.
func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.WaterfallTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
spanItems, err := m.store.GetTraceSpans(ctx, traceID, summary)
if err != nil {
return nil, err
}
if len(spanItems) == 0 {
return nil, spantypes.ErrTraceNotFound
}
nodes := make([]*spantypes.WaterfallSpan, len(spanItems))
for i := range spanItems {
nodes[i] = spanItems[i].ToWaterfallSpan(traceID)
}
return spantypes.NewWaterfallTraceFromSpans(nodes), nil
}
// GetWaterfallV4 is the OOM-safe V4 waterfall.
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
// minimal fields for all spans to build the tree, then full fields for the
// visible window only. Aggregations are not returned.
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error) {
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
effectiveLimit := min(selectAllLimit, m.config.Waterfall.MaxLimitToSelectAllSpans)
if summary.NumSpans > uint64(effectiveLimit) {
if summary.NumSpans > uint64(m.config.Waterfall.MaxLimitToSelectAllSpans) {
attrs := metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed))
m.metrics.waterfallRequestCount.Add(ctx, 1, attrs)
m.metrics.waterfallSpanCount.Add(ctx, int64(summary.NumSpans), attrs)
@@ -118,7 +74,7 @@ func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *
waterfallTrace := spantypes.NewWaterfallTraceFromSpans(nodes)
selectedSpans := waterfallTrace.GetAllSpans()
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, nil, true, nil), nil
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, nil, true), nil
}
func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error) {
@@ -164,6 +120,18 @@ func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *
return &spantypes.GettableTraceAggregations{Aggregations: results}, nil
}
func (m *module) GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
if summary.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
return m.getFullFlamegraph(ctx, traceID, summary, selectFields)
}
m.metrics.flamegraphRequestCount.Add(ctx, 1, metric.WithAttributes(attrResponseType.String(attrResponseTypeSampled)))
return m.getWindowedFlamegraph(ctx, traceID, selectedSpanID, summary, selectFields)
}
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
// Step 1: minimal fetch → build full tree → select visible window
@@ -201,6 +169,50 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
spantypes.EnrichSelectedSpans(selectedSpans, fullSpans)
return spantypes.NewGettableWaterfallTrace(
waterfallTrace, selectedSpans, uncollapsedSpans, false, nil,
waterfallTrace, selectedSpans, uncollapsedSpans, false,
), nil
}
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, nil)
if err != nil {
return nil, err
}
if len(fullSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
flamegraphTrace := spantypes.NewFlamegraphTraceFromStorable(fullSpans, selectFields)
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start.UnixMilli(), summary.End.UnixMilli(), false), nil
}
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, summary.Start, summary.End)
if err != nil {
return nil, err
}
if len(minimalSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
flamegraphTrace := spantypes.NewFlamegraphTraceFromMinimal(minimalSpans)
minimalSpans = nil //nolint:ineffassign,wastedassign // release backing array before further db calls
cfg := m.config.Flamegraph
selectedSpans := flamegraphTrace.GetSelectedLevels(selectedSpanID, cfg.MaxSelectedLevels, cfg.MaxSpansPerLevel, cfg.SamplingTopLatencySpansCount, cfg.SamplingBucketCount)
if len(selectedSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, spantypes.FlamegraphWindowSpanIDs(selectedSpans))
if err != nil {
return nil, err
}
return spantypes.NewGettableFlamegraphTrace(
flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields),
summary.Start.UnixMilli(),
summary.End.UnixMilli(),
true,
), nil
}

View File

@@ -154,6 +154,47 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
return spans, nil
}
func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"span_id",
"any(parent_span_id) AS parent_span_id",
"any(timestamp) AS timestamp",
"any(duration_nano) AS duration_nano",
"any(has_error) AS has_error",
"any(name) AS name",
"any(events) AS events",
"any(attributes_string) AS attributes_string",
"any(attributes_number) AS attributes_number",
"any(attributes_bool) AS attributes_bool",
"any(resources_string) AS resources_string",
)
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
conditions := []string{
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", start.Unix()-1800),
sb.LE("ts_bucket_start", end.Unix()),
}
if len(spanIDs) > 0 {
ids := make([]any, len(spanIDs))
for i, id := range spanIDs {
ids[i] = id
}
conditions = append(conditions, sb.In("span_id", ids...))
}
sb.Where(conditions...)
sb.GroupBy("span_id")
sb.OrderByAsc("timestamp")
sb.OrderByAsc("name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var spans []spantypes.StorableSpan
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying flamegraph spans")
}
return spans, nil
}
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
fieldExpr, err := buildFieldExpr(fieldKey)
if err != nil {

View File

@@ -91,6 +91,30 @@ func TestGetSpanCountByField(t *testing.T) {
}
}
func TestGetFlamegraphSpans(t *testing.T) {
baseSQL := "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes_string) AS attributes_string, any(attributes_number) AS attributes_number, any(attributes_bool) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY span_id ORDER BY timestamp ASC, name ASC"
withSpanIDsSQL := "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes_string) AS attributes_string, any(attributes_number) AS attributes_number, any(attributes_bool) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND span_id IN (?, ?) GROUP BY span_id ORDER BY timestamp ASC, name ASC"
tests := []struct {
name string
spanIDs []string
sql string
}{
{name: "NoSpanIDs_GeneratesBaseSQL", spanIDs: nil, sql: baseSQL},
{name: "WithSpanIDs_GeneratesInClauseSQL", spanIDs: []string{"span-1", "span-2"}, sql: withSpanIDsSQL},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := newTestStore(sqlmock.QueryMatcherRegexp)
s.Mock().ExpectSelect(regexp.QuoteMeta(tc.sql)).
WillReturnRows(cmock.NewRows(nil, nil))
_, _ = s.Store().GetFlamegraphSpans(context.Background(), testTraceID, testStart, testEnd, tc.spanIDs)
assert.NoError(t, s.Mock().ExpectationsWereMet())
})
}
}
func TestGetSpanDurationByField(t *testing.T) {
expectedSQL := "WITH all_spans AS (SELECT DISTINCT ON (span_id) resource.`service.name`::String AS field_value, toUnixTimestamp64Nano(timestamp) AS start_ns, start_ns + duration_nano AS end_ns FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND notEmpty(field_value) ORDER BY timestamp ASC, name ASC), effective_start AS (SELECT field_value, end_ns, greatest(start_ns, ifNull(max(end_ns) OVER (PARTITION BY field_value ORDER BY start_ns ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), toUInt64(0))) AS effective_start_ns FROM all_spans) SELECT field_value, sum(toUInt64(greatest(end_ns - effective_start_ns, 0))) AS total_ns FROM effective_start GROUP BY field_value"

View File

@@ -9,12 +9,16 @@ import (
const (
attrResponseType = attribute.Key("response_type")
attrResponseTypeWindowed = "windowed"
attrResponseTypeSampled = "sampled"
)
type moduleMetrics struct {
waterfallSpanLimit metric.Int64Gauge
waterfallRequestCount metric.Int64Counter
waterfallSpanCount metric.Int64Counter
flamegraphSpanLimit metric.Int64Gauge
flamegraphRequestCount metric.Int64Counter
}
func newModuleMetrics(meter metric.Meter) (*moduleMetrics, error) {
@@ -47,9 +51,30 @@ func newModuleMetrics(meter metric.Meter) (*moduleMetrics, error) {
errs = errors.Join(errs, err)
}
flamegraphSpanLimit, err := meter.Int64Gauge(
"signoz.traces.flamegraph.span.limit",
metric.WithDescription("The span count limit above which sampled flamegraph is returned instead of the full flamegraph."),
metric.WithUnit("{span}"),
)
if err != nil {
errs = errors.Join(errs, err)
}
flamegraphRequestCount, err := meter.Int64Counter(
"signoz.traces.flamegraph.request.count",
metric.WithDescription("Total number of flamegraph requests, by response_type."),
metric.WithUnit("{request}"),
)
if err != nil {
errs = errors.Join(errs, err)
}
return &moduleMetrics{
waterfallSpanLimit: spanLimit,
waterfallRequestCount: requestCount,
waterfallSpanCount: spanCount,
flamegraphSpanLimit: flamegraphSpanLimit,
flamegraphRequestCount: flamegraphRequestCount,
}, errs
}

View File

@@ -260,7 +260,7 @@ func TestGetSelectedSpans_MultipleRoots(t *testing.T) {
trace := getWaterfallTrace([]*spantypes.WaterfallSpan{root1, root2}, spanMap)
spans, _ := trace.GetSelectedSpans([]string{"root1", "root2"}, "root1", 500, 5)
traceRespnose := spantypes.NewGettableWaterfallTrace(trace, spans, nil, false, nil)
traceRespnose := spantypes.NewGettableWaterfallTrace(trace, spans, nil, false)
assert.Equal(t, []string{"root1", "child1", "root2", "child2"}, spanIDs(spans), "root1 subtree must precede root2 subtree")
assert.Equal(t, "svc-a", traceRespnose.RootServiceName, "metadata comes from first root")
@@ -567,7 +567,7 @@ func TestGetAllSpans(t *testing.T) {
)
trace := getWaterfallTrace([]*spantypes.WaterfallSpan{root}, nil)
spans := trace.GetAllSpans()
traceResponse := spantypes.NewGettableWaterfallTrace(trace, spans, nil, true, nil)
traceResponse := spantypes.NewGettableWaterfallTrace(trace, spans, nil, true)
assert.ElementsMatch(t, spanIDs(spans), []string{"root", "childA", "grandchildA", "leafA", "childB", "grandchildB", "leafB"})
assert.Equal(t, "svc", traceResponse.RootServiceName)
assert.Equal(t, "root-op", traceResponse.RootServiceEntryPoint)

View File

@@ -5,18 +5,19 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// Handler exposes HTTP handlers for trace detail APIs.
type Handler interface {
GetWaterfall(http.ResponseWriter, *http.Request)
GetWaterfallV4(http.ResponseWriter, *http.Request)
GetTraceAggregations(http.ResponseWriter, *http.Request)
GetFlamegraph(http.ResponseWriter, *http.Request)
}
// Module defines the business logic for trace detail operations.
type Module interface {
GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error)
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
}

View File

@@ -18,7 +18,6 @@ import (
"github.com/uptrace/bun"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types"
@@ -47,7 +46,6 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/app/resource"
"github.com/SigNoz/signoz/pkg/query-service/app/services"
"github.com/SigNoz/signoz/pkg/query-service/app/traces/smart"
"github.com/SigNoz/signoz/pkg/query-service/app/traces/tracedetail"
"github.com/SigNoz/signoz/pkg/query-service/common"
"github.com/SigNoz/signoz/pkg/query-service/constants"
@@ -898,390 +896,6 @@ func (r *ClickHouseReader) GetSpansForTrace(ctx context.Context, traceID string,
return searchScanResponses, nil
}
func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadataCache(ctx context.Context, orgID valuer.UUID, traceID string) (*model.GetWaterfallSpansForTraceWithMetadataCache, error) {
cachedTraceData := new(model.GetWaterfallSpansForTraceWithMetadataCache)
err := r.cacheForTraceDetail.Get(ctx, orgID, strings.Join([]string{"getWaterfallSpansForTraceWithMetadata", traceID}, "-"), cachedTraceData)
if err != nil {
r.logger.Debug("error in retrieving getWaterfallSpansForTraceWithMetadata cache", errorsV2.Attr(err), "traceID", traceID)
return nil, err
}
if time.Since(time.UnixMilli(int64(cachedTraceData.EndTime))) < r.fluxIntervalForTraceDetail {
r.logger.Info("the trace end time falls under the flux interval, skipping getWaterfallSpansForTraceWithMetadata cache", "traceID", traceID)
return nil, errors.Errorf("the trace end time falls under the flux interval, skipping getWaterfallSpansForTraceWithMetadata cache, traceID: %s", traceID)
}
r.logger.Info("cache is successfully hit, applying cache for getWaterfallSpansForTraceWithMetadata", "traceID", traceID)
return cachedTraceData, nil
}
func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetWaterfallSpansForTraceWithMetadataParams) (*model.GetWaterfallSpansForTraceWithMetadataResponse, error) {
response := new(model.GetWaterfallSpansForTraceWithMetadataResponse)
var startTime, endTime, durationNano, totalErrorSpans, totalSpans uint64
var spanIdToSpanNodeMap = map[string]*model.Span{}
var traceRoots []*model.Span
var serviceNameToTotalDurationMap = map[string]uint64{}
var serviceNameIntervalMap = map[string][]tracedetail.Interval{}
var hasMissingSpans bool
cachedTraceData, err := r.GetWaterfallSpansForTraceWithMetadataCache(ctx, orgID, traceID)
if err == nil {
startTime = cachedTraceData.StartTime
endTime = cachedTraceData.EndTime
durationNano = cachedTraceData.DurationNano
spanIdToSpanNodeMap = cachedTraceData.SpanIdToSpanNodeMap
serviceNameToTotalDurationMap = cachedTraceData.ServiceNameToTotalDurationMap
traceRoots = cachedTraceData.TraceRoots
totalSpans = cachedTraceData.TotalSpans
totalErrorSpans = cachedTraceData.TotalErrorSpans
hasMissingSpans = cachedTraceData.HasMissingSpans
}
if err != nil {
r.logger.Info("cache miss for getWaterfallSpansForTraceWithMetadata", "traceID", traceID)
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, fmt.Sprintf("SELECT DISTINCT ON (span_id) timestamp, duration_nano, span_id, trace_id, has_error, kind, resource_string_service$$name, name, links as references, attributes_string, attributes_number, attributes_bool, resources_string, events, status_message, status_code_string, kind_string FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", r.TraceDB, r.traceTableName))
if err != nil {
return nil, err
}
if len(searchScanResponses) == 0 {
return response, nil
}
totalSpans = uint64(len(searchScanResponses))
for _, item := range searchScanResponses {
ref := []model.OtelSpanRef{}
err := json.Unmarshal([]byte(item.References), &ref)
if err != nil {
r.logger.Error("getWaterfallSpansForTraceWithMetadata: error unmarshalling references", errorsV2.Attr(err), "traceID", traceID)
return nil, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "getWaterfallSpansForTraceWithMetadata: error unmarshalling references %s", err.Error())
}
// merge attributes_number and attributes_bool to attributes_string
for k, v := range item.Attributes_bool {
item.Attributes_string[k] = fmt.Sprintf("%v", v)
}
for k, v := range item.Attributes_number {
item.Attributes_string[k] = strconv.FormatFloat(v, 'f', -1, 64)
}
for k, v := range item.Resources_string {
item.Attributes_string[k] = v
}
events := make([]model.Event, 0)
for _, event := range item.Events {
var eventMap model.Event
err = json.Unmarshal([]byte(event), &eventMap)
if err != nil {
r.logger.Error("Error unmarshalling events", errorsV2.Attr(err))
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getWaterfallSpansForTraceWithMetadata: error in unmarshalling events %s", err.Error())
}
events = append(events, eventMap)
}
startTimeUnixNano := uint64(item.TimeUnixNano.UnixNano())
jsonItem := model.Span{
SpanID: item.SpanID,
TraceID: item.TraceID,
ServiceName: item.ServiceName,
Name: item.Name,
Kind: int32(item.Kind),
DurationNano: item.DurationNano,
HasError: item.HasError,
StatusMessage: item.StatusMessage,
StatusCodeString: item.StatusCodeString,
SpanKind: item.SpanKind,
References: ref,
Events: events,
TagMap: item.Attributes_string,
Children: make([]*model.Span, 0),
TimeUnixNano: startTimeUnixNano, // Store nanoseconds temporarily
}
// metadata calculation
if startTime == 0 || startTimeUnixNano < startTime {
startTime = startTimeUnixNano
}
if endTime == 0 || (startTimeUnixNano+jsonItem.DurationNano) > endTime {
endTime = (startTimeUnixNano + jsonItem.DurationNano)
}
if durationNano == 0 || jsonItem.DurationNano > durationNano {
durationNano = jsonItem.DurationNano
}
if jsonItem.HasError {
totalErrorSpans = totalErrorSpans + 1
}
// collect the intervals for service for execution time calculation
serviceNameIntervalMap[jsonItem.ServiceName] =
append(serviceNameIntervalMap[jsonItem.ServiceName], tracedetail.Interval{StartTime: jsonItem.TimeUnixNano, Duration: jsonItem.DurationNano, Service: jsonItem.ServiceName})
// append to the span node map
spanIdToSpanNodeMap[jsonItem.SpanID] = &jsonItem
}
// traverse through the map and append each node to the children array of the parent node
// and add the missing spans
for _, spanNode := range spanIdToSpanNodeMap {
hasParentSpanNode := false
for _, reference := range spanNode.References {
if reference.RefType == "CHILD_OF" && reference.SpanId != "" {
hasParentSpanNode = true
if parentNode, exists := spanIdToSpanNodeMap[reference.SpanId]; exists {
parentNode.Children = append(parentNode.Children, spanNode)
} else {
// insert the missing span
missingSpan := model.Span{
SpanID: reference.SpanId,
TraceID: spanNode.TraceID,
ServiceName: "",
Name: "Missing Span",
TimeUnixNano: spanNode.TimeUnixNano,
Kind: 0,
DurationNano: spanNode.DurationNano,
HasError: false,
StatusMessage: "",
StatusCodeString: "",
SpanKind: "",
Events: make([]model.Event, 0),
Children: make([]*model.Span, 0),
}
missingSpan.Children = append(missingSpan.Children, spanNode)
spanIdToSpanNodeMap[missingSpan.SpanID] = &missingSpan
traceRoots = append(traceRoots, &missingSpan)
hasMissingSpans = true
}
}
}
if !hasParentSpanNode && !tracedetail.ContainsWaterfallSpan(traceRoots, spanNode) {
traceRoots = append(traceRoots, spanNode)
}
}
// sort the trace roots to add missing spans at the right order
sort.Slice(traceRoots, func(i, j int) bool {
if traceRoots[i].TimeUnixNano == traceRoots[j].TimeUnixNano {
return traceRoots[i].Name < traceRoots[j].Name
}
return traceRoots[i].TimeUnixNano < traceRoots[j].TimeUnixNano
})
serviceNameToTotalDurationMap = tracedetail.CalculateServiceTime(serviceNameIntervalMap)
// TODO: set the span data (model.GetWaterfallSpansForTraceWithMetadataCache) in cache here
// removed existing cache usage since it was not getting used due to this bug https://github.com/SigNoz/engineering-pod/issues/4648
// and was causing out of memory issues https://github.com/SigNoz/engineering-pod/issues/4638
}
processingPostCache := time.Now()
// When req.Limit is 0 (not set by the client), selectAllSpans is set to false
// preserving the old paged behaviour for backward compatibility
limit := min(req.Limit, tracedetail.MaxLimitToSelectAllSpans)
selectAllSpans := totalSpans <= uint64(limit)
var (
selectedSpans []*model.Span
uncollapsedSpans []string
rootServiceName, rootServiceEntryPoint string
)
if selectAllSpans {
selectedSpans, rootServiceName, rootServiceEntryPoint = tracedetail.GetAllSpans(traceRoots)
} else {
selectedSpans, uncollapsedSpans, rootServiceName, rootServiceEntryPoint = tracedetail.GetSelectedSpans(req.UncollapsedSpans, req.SelectedSpanID, traceRoots, spanIdToSpanNodeMap, req.IsSelectedSpanIDUnCollapsed)
}
r.logger.Info("getWaterfallSpansForTraceWithMetadata: processing post cache", "duration", time.Since(processingPostCache), "traceID", traceID)
// convert start timestamp to millis because right now frontend is expecting it in millis
for _, span := range selectedSpans {
span.TimeUnixNano = span.TimeUnixNano / 1000000
}
for serviceName, totalDuration := range serviceNameToTotalDurationMap {
serviceNameToTotalDurationMap[serviceName] = totalDuration / 1000000
}
response.Spans = selectedSpans
response.UncollapsedSpans = uncollapsedSpans // ignoring if all spans are returning
response.StartTimestampMillis = startTime / 1000000
response.EndTimestampMillis = endTime / 1000000
response.TotalSpansCount = totalSpans
response.TotalErrorSpansCount = totalErrorSpans
response.RootServiceName = rootServiceName
response.RootServiceEntryPoint = rootServiceEntryPoint
response.ServiceNameToTotalDurationMap = serviceNameToTotalDurationMap
response.HasMissingSpans = hasMissingSpans
response.HasMore = !selectAllSpans
return response, nil
}
func (r *ClickHouseReader) GetFlamegraphSpansForTraceCache(ctx context.Context, orgID valuer.UUID, traceID string) (*model.GetFlamegraphSpansForTraceCache, error) {
cachedTraceData := new(model.GetFlamegraphSpansForTraceCache)
err := r.cacheForTraceDetail.Get(ctx, orgID, strings.Join([]string{"getFlamegraphSpansForTrace", traceID}, "-"), cachedTraceData)
if err != nil {
r.logger.Debug("error in retrieving getFlamegraphSpansForTrace cache", errorsV2.Attr(err), "traceID", traceID)
return nil, err
}
if time.Since(time.UnixMilli(int64(cachedTraceData.EndTime))) < r.fluxIntervalForTraceDetail {
r.logger.Info("the trace end time falls under the flux interval, skipping getFlamegraphSpansForTrace cache", "traceID", traceID)
return nil, errors.Errorf("the trace end time falls under the flux interval, skipping getFlamegraphSpansForTrace cache, traceID: %s", traceID)
}
r.logger.Info("cache is successfully hit, applying cache for getFlamegraphSpansForTrace", "traceID", traceID)
return cachedTraceData, nil
}
func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetFlamegraphSpansForTraceParams) (*model.GetFlamegraphSpansForTraceResponse, error) {
trace := new(model.GetFlamegraphSpansForTraceResponse)
var startTime, endTime, durationNano uint64
var spanIdToSpanNodeMap = map[string]*model.FlamegraphSpan{}
// map[traceID][level]span
var selectedSpans = [][]*model.FlamegraphSpan{}
var traceRoots []*model.FlamegraphSpan
// get the trace tree from cache!
cachedTraceData, err := r.GetFlamegraphSpansForTraceCache(ctx, orgID, traceID)
if err == nil {
startTime = cachedTraceData.StartTime
endTime = cachedTraceData.EndTime
durationNano = cachedTraceData.DurationNano
selectedSpans = cachedTraceData.SelectedSpans
traceRoots = cachedTraceData.TraceRoots
}
if err != nil {
r.logger.Info("cache miss for getFlamegraphSpansForTrace", "traceID", traceID)
selectCols := "timestamp, duration_nano, span_id, trace_id, has_error, links as references, resource_string_service$$name, name, events"
if len(req.SelectFields) > 0 {
selectCols += ", attributes_string, attributes_number, attributes_bool, resources_string"
}
flamegraphQuery := fmt.Sprintf("SELECT %s FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", selectCols, r.TraceDB, r.traceTableName)
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, flamegraphQuery)
if err != nil {
return nil, err
}
if len(searchScanResponses) == 0 {
return trace, nil
}
for _, item := range searchScanResponses {
ref := []model.OtelSpanRef{}
err := json.Unmarshal([]byte(item.References), &ref)
if err != nil {
r.logger.Error("Error unmarshalling references", errorsV2.Attr(err))
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getFlamegraphSpansForTrace: error in unmarshalling references %s", err.Error())
}
events := make([]model.Event, 0)
for _, event := range item.Events {
var eventMap model.Event
err = json.Unmarshal([]byte(event), &eventMap)
if err != nil {
r.logger.Error("Error unmarshalling events", errorsV2.Attr(err))
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getFlamegraphSpansForTrace: error in unmarshalling events %s", err.Error())
}
events = append(events, eventMap)
}
jsonItem := model.FlamegraphSpan{
SpanID: item.SpanID,
TraceID: item.TraceID,
ServiceName: item.ServiceName,
Name: item.Name,
DurationNano: item.DurationNano,
HasError: item.HasError,
References: ref,
Events: events,
Children: make([]*model.FlamegraphSpan, 0),
}
if len(req.SelectFields) > 0 {
jsonItem.SetRequestedFields(item, req.SelectFields)
}
// metadata calculation
startTimeUnixNano := uint64(item.TimeUnixNano.UnixNano())
if startTime == 0 || startTimeUnixNano < startTime {
startTime = startTimeUnixNano
}
if endTime == 0 || (startTimeUnixNano+jsonItem.DurationNano) > endTime {
endTime = (startTimeUnixNano + jsonItem.DurationNano)
}
if durationNano == 0 || jsonItem.DurationNano > durationNano {
durationNano = jsonItem.DurationNano
}
jsonItem.TimeUnixNano = uint64(item.TimeUnixNano.UnixNano() / 1000000)
spanIdToSpanNodeMap[jsonItem.SpanID] = &jsonItem
}
// traverse through the map and append each node to the children array of the parent node
// and add missing spans
for _, spanNode := range spanIdToSpanNodeMap {
hasParentSpanNode := false
for _, reference := range spanNode.References {
if reference.RefType == "CHILD_OF" && reference.SpanId != "" {
hasParentSpanNode = true
if parentNode, exists := spanIdToSpanNodeMap[reference.SpanId]; exists {
parentNode.Children = append(parentNode.Children, spanNode)
} else {
// insert the missing spans
missingSpan := model.FlamegraphSpan{
SpanID: reference.SpanId,
TraceID: spanNode.TraceID,
ServiceName: "",
Name: "Missing Span",
TimeUnixNano: spanNode.TimeUnixNano,
DurationNano: spanNode.DurationNano,
HasError: false,
Events: make([]model.Event, 0),
Children: make([]*model.FlamegraphSpan, 0),
}
missingSpan.Children = append(missingSpan.Children, spanNode)
spanIdToSpanNodeMap[missingSpan.SpanID] = &missingSpan
traceRoots = append(traceRoots, &missingSpan)
}
}
}
if !hasParentSpanNode && !tracedetail.ContainsFlamegraphSpan(traceRoots, spanNode) {
traceRoots = append(traceRoots, spanNode)
}
}
selectedSpans = tracedetail.GetAllSpansForFlamegraph(traceRoots, spanIdToSpanNodeMap)
// TODO: set the trace data (model.GetFlamegraphSpansForTraceCache) in cache here
// removed existing cache usage since it was not getting used due to this bug https://github.com/SigNoz/engineering-pod/issues/4648
// and was causing out of memory issues https://github.com/SigNoz/engineering-pod/issues/4638
}
processingPostCache := time.Now()
selectedSpansForRequest := selectedSpans
clientLimit := min(req.Limit, tracedetail.MaxLimitWithoutSampling)
totalSpanCount := tracedetail.GetTotalSpanCount(selectedSpans)
if totalSpanCount > uint64(clientLimit) {
// using trace start and end time if boundary ts are set to zero (or not set)
boundaryStart := max(timestamp.MilliToNano(req.BoundaryStartTS), startTime)
boundaryEnd := timestamp.MilliToNano(req.BoundaryEndTS)
if boundaryEnd == 0 {
boundaryEnd = endTime
}
selectedSpansForRequest = tracedetail.GetSelectedSpansForFlamegraphForRequest(req.SelectedSpanID, selectedSpans, boundaryStart, boundaryEnd)
}
r.logger.Debug("getFlamegraphSpansForTrace: processing post cache", "duration", time.Since(processingPostCache), "traceID", traceID, "totalSpans", totalSpanCount, "limit", clientLimit)
trace.Spans = selectedSpansForRequest
trace.StartTimestampMillis = startTime / 1000000
trace.EndTimestampMillis = endTime / 1000000
trace.HasMore = totalSpanCount > uint64(clientLimit)
return trace, nil
}
func (r *ClickHouseReader) GetDependencyGraph(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error) {

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