Compare commits

...

18 Commits

Author SHA1 Message Date
Naman Verma
845c88ec45 Merge branch 'main' into nv/schema-changes 2026-06-15 16:55:36 +05:30
Ashwin Bhatkal
e206625e5f feat(dashboard-v2): public dashboard settings section (#11696)
* feat(dashboard-v2): add public dashboard settings section

Add a self-contained Public Dashboard settings section for the V2 dashboard
drawer, reusing the existing /dashboards/{id}/public endpoint via the generated
client (useGetPublicDashboard / useCreatePublicDashboard /
useUpdatePublicDashboard / useDeletePublicDashboard).

Broken into small components: a usePublicDashboard hook owning the query, the
create/update/delete mutations and the time-range form state; plus presentational
status, settings-form, URL, callout and actions pieces. Publishing a dashboard
exposes a shareable URL and lets the viewer default time range be configured.

* feat(dashboard-v2): wire public dashboard tab in settings drawer

Render the Public Dashboard section in the Publish tab in place of the
placeholder, and drop the now-unused SettingsTabPlaceholder util (the Publish tab
was its last consumer).

* fix: format failure
2026-06-15 10:59:56 +00:00
Naman Verma
9b53561c31 fix: change schema properties based on UI integration review 2026-06-15 15:37:29 +05:30
Nikhil Mantri
4f3b7647d3 feat(infra-monitoring): v2 daemonsets integration tests (#11442)
* chore: updated logic and use centralized function in the module

* chore: filter metric groups

* chore: filter metric groups

* chore: formula correction

* chore: added step flooring note

* chore: comment correction

* chore: comment correction

* chore: removed function

* chore: renamed variables

* chore: added happy test

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

* chore: added filter test 4

* chore: added 5th test for filterByStatus

* chore: added group by tests

* chore: pagination test added

* chore: added validation tests

* chore: added auth test

* chore: added all tests

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

* chore: added nodes integration test suite

* chore: namespaces integration tests

* ci: register inframonitoring suite + ruff format 01_hosts

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

* chore: added integration tests for clusters

* chore: formatting changed

* chore: formatting changed

* chore: formatting changed

* chore: added volumes integration tests

* chore: added deployments

* chore: added tests

* chore: added integration tests for jobs api

* chore: added integration tests for v2 daemonsets api

* chore: added order by host.name test

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- merge happy_path into test_jobs_accuracy; drop unused jobs_happy_path.jsonl
- merge orderby invariant + correctness + by-job-name into test_jobs_orderby
- fold offset-beyond-total into test_jobs_pagination page walk

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

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

- merge happy_path into test_daemonsets_accuracy; drop unused daemonsets_happy_path.jsonl
- merge orderby invariant + correctness + by-daemonset-name into test_daemonsets_orderby
- fold offset-beyond-total into test_daemonsets_pagination page walk

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- regenerate jobs_filter_dataset.jsonl so every job mirrors the acc-job-1
  sample pattern (2 pods) from jobs_value_accuracy.jsonl
- test_jobs_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 daemonsets filter tests

- regenerate daemonsets_filter_dataset.jsonl so every daemonset mirrors the
  acc-ds-1 sample pattern (2 pods) from daemonsets_value_accuracy.jsonl
- test_daemonsets_filter now asserts the 6 CPU/memory fields per filtered record

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 06:51:47 +00:00
Ashwin Bhatkal
76ab0f25c0 feat(dashboard-v2): consolidate dashboard detail header, toolbar & settings (#11713)
* feat(dashboard-v2): consolidate dashboard page header & breadcrumbs

Replace DashboardHeader/DashboardBreadcrumbs with a single DashboardPageHeader
(title + image + breadcrumbs) under components/DashboardPageHeader.

* feat(dashboard-v2): consolidate dashboard detail toolbar

Fold the old DashboardDescription (title/meta) into a DashboardPageToolbar with
DashboardInfo (inline-editable title, tags, public/lock badges), DashboardActions
and the settings drawer; drop the separate DashboardMeta/DashboardTitle pieces.

* feat(dashboard-v2): rename General settings to Overview with shared settings card

Rename the General settings tab to Overview, extract DashboardInfoForm and a
reusable SegmentedControl, and restructure the settings drawer tabs onto the
shared settings-card layout. Publish tab stays a placeholder.

* feat(dashboard-v2): wire consolidated header & toolbar into the container

Render DashboardPageHeader + DashboardPageToolbar from DashboardContainer and
drop the removed header/description wiring.
2026-06-15 05:34:53 +00:00
Nikhil Mantri
59501ce4a7 feat(infra-monitoring): v2 jobs integration tests (#11441)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: updated logic and use centralized function in the module

* chore: filter metric groups

* chore: filter metric groups

* chore: formula correction

* chore: added step flooring note

* chore: comment correction

* chore: comment correction

* chore: removed function

* chore: renamed variables

* chore: added happy test

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

* chore: added filter test 4

* chore: added 5th test for filterByStatus

* chore: added group by tests

* chore: pagination test added

* chore: added validation tests

* chore: added auth test

* chore: added all tests

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

* chore: added nodes integration test suite

* chore: namespaces integration tests

* ci: register inframonitoring suite + ruff format 01_hosts

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

* chore: added integration tests for clusters

* chore: formatting changed

* chore: formatting changed

* chore: formatting changed

* chore: added volumes integration tests

* chore: added deployments

* chore: added tests

* chore: added integration tests for jobs api

* chore: added order by host.name test

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- merge happy_path into test_jobs_accuracy; drop unused jobs_happy_path.jsonl
- merge orderby invariant + correctness + by-job-name into test_jobs_orderby
- fold offset-beyond-total into test_jobs_pagination page walk

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- regenerate jobs_filter_dataset.jsonl so every job mirrors the acc-job-1
  sample pattern (2 pods) from jobs_value_accuracy.jsonl
- test_jobs_filter now asserts the 6 CPU/memory fields per filtered record

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 09:27:20 +00:00
Jatinderjit Singh
3e51b9556e refactor(planned-maintenance): remove time bounds from recurrence (#11500)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / staging (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
* feat(alertmanager): migrate recurrence bounds to schedule level

Promote startTime/endTime from a planned maintenance's nested recurrence
up to the schedule level. For recurring maintenances the recurrence
bounds were the source of truth; the recurrence struct loses these
fields in the next step, so the values are moved while they can still be
read. The migration operates on raw JSON for that reason.

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

* refactor(alertmanager): drop start/end bounds from Recurrence

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

* refactor: code cleanup

* fix: upcoming check for recurring maintenances

* fix: remove recurrence.startTime/endTime usages

* fix: use embedded timezone in start/end times

Accept times in any timezone, but always convert them to the selected
timezone. The conversion is required to correctly handle the recurring
maintenances for timezones where DST is involved.

* refactor: remove redundant code

* fix: make startTime a required field

* test: cover fixed schedule active window in IsActive

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

* test: cover recurring schedule active window in IsActive

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

* chore: add justification for unreachable code

* fix: don't let one corrupt maintenance abort the migration

* fix: don't let one corrupt maintenance break the list

ListPlannedMaintenance now reads the schedule as raw text and parses each
row individually, skipping and logging the bad ones so the rest survive.

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

* fix: tests to use UTC instead of utc

* fix: return proper errors from schedule.Unmarshal

* fix: copy schedule type to migration

* chore: move tz conversion to checkX methods

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-12 20:48:08 +00:00
SagarRajput-7
abd4436388 fix(settings): trial banner causing the scroll of the sub-pages to be broken (#11684)
* fix(billing): fix bottom content clipped on free trial due to viewport height mismatch

* fix(workspace): fix callout overflow and sign-out text color regression
2026-06-12 20:11:29 +00:00
SagarRajput-7
30f52ecb6d fix(settings): guard against non-APIError in logs retention error state (#11685) 2026-06-12 18:57:03 +00:00
Vinicius Lourenço
629d24547c fix(infra-monitoring-volumes): add missing inodes columns (#11683)
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
* refactor(volumes): remove volume prefix

* fix(volumes): add missing inode columns

* fix(volumes): change to used from utilization
2026-06-12 18:47:01 +00:00
Tushar Vats
080aae9567 fix: mark numeric columns as aggregation in scalar query response (#11593)
* fix: mark numeric columns as aggregation in scalar query response

* fix: make py-fmt

* fix: comments
2026-06-12 12:34:16 +00:00
Vinicius Lourenço
45a9183c82 fix(infra-monitoring-details): ensure events/traces uses timestamp adjusted by the timezone (#11644)
* refactor(timezone-formatter): expose type for format function

* fix(infra-monitoring-details): ensure events/traces uses timestamp adjusted by the timezone
2026-06-12 12:22:33 +00:00
Vinicius Lourenço
76e7e88641 fix(infra-monitoring-clusters): deployments desired should use latest instead of avg (#11681) 2026-06-12 12:21:55 +00:00
Vinicius Lourenço
1b7954faaf fix(infra-monitoring-k8s-pods): working set memory should use space aggregation sum (#11680) 2026-06-12 12:03:43 +00:00
Naman Verma
6f79d6b18d test: use v1 dashboards list API in cleanup (#11688)
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
* test: check for response status in integration test before initiating cleanup

* fix: use v1 list api in cleanup
2026-06-12 06:15:40 +00:00
Aditya Singh
e57a9556e3 feat(traces): integrate flamegraph v3 API (#11648)
* feat: added new flamegraph v3 query hook

* feat: change normalise timestamp value from backend

* feat: remove references use from visual compute to find parentID

* feat: api integration and type fixes

* feat: test updates

* feat: prevent flamegraph call till user pref arrive

* fix: keep flamegraph span ts in milli like others

* fix: remove fg span timestamp unit conversion

Since it's changed in api response

* feat: revert timestamp conversion to ms

---------

Co-authored-by: Nikhil Soni <nikhil.soni@signoz.io>
2026-06-12 05:58:54 +00:00
Ashwin Bhatkal
bf35748db5 feat(dashboard-v2): variables settings tab (#11645)
* feat(dashboard-v2): variable model, adapters & patch builder

Flat VariableFormModel + adapters between the nested envelope/plugin DTO union
(ListVariable{Query,Custom,Dynamic} / TextVariable) and the model, plus a
JSON-patch builder that replaces /spec/variables atomically. Pure, no UI.

* feat(dashboard-v2): variable editor form for all variable types

In-drawer master-detail editor reproducing the V1 VariableItem layout with
@signozhq components: segmented type selector, per-type bodies (Custom comma
values, Text default + constant, Query editor + test-run preview, Dynamic
signal + field autocomplete) and the shared preview / sort / multi-select /
ALL / default-value rows.

* feat(dashboard-v2): variables settings tab — list, CRUD & persistence
2026-06-12 05:52:06 +00:00
Nikhil Mantri
2781f73057 chore: added labels for infra-monitoring method to drill down on clickhouse query_log queries (#11638) 2026-06-12 05:45:38 +00:00
137 changed files with 8712 additions and 1726 deletions

View File

@@ -409,10 +409,6 @@ components:
properties:
duration:
type: string
endTime:
format: date-time
nullable: true
type: string
repeatOn:
items:
$ref: '#/components/schemas/AlertmanagertypesRepeatOn'
@@ -420,11 +416,7 @@ components:
type: array
repeatType:
$ref: '#/components/schemas/AlertmanagertypesRepeatType'
startTime:
format: date-time
type: string
required:
- startTime
- duration
- repeatType
type: object
@@ -458,6 +450,7 @@ components:
type: string
required:
- timezone
- startTime
type: object
AuthtypesAttributeMapping:
properties:
@@ -2776,9 +2769,16 @@ components:
type: string
nullable: true
type: object
mode:
$ref: '#/components/schemas/DashboardtypesLegendMode'
position:
$ref: '#/components/schemas/DashboardtypesLegendPosition'
type: object
DashboardtypesLegendMode:
enum:
- list
- table
type: string
DashboardtypesLegendPosition:
enum:
- bottom
@@ -3328,8 +3328,13 @@ components:
DashboardtypesSpanGaps:
properties:
fillLessThan:
description: The maximum gap size to connect when fillOnlyBelow is true.
Gaps larger than this duration are left disconnected.
type: string
fillOnlyBelow:
description: Controls whether lines connect across null values. When false
(default), all gaps are connected. When true, only gaps smaller than fillLessThan
are connected.
type: boolean
type: object
DashboardtypesStorableDashboardData:
@@ -3396,7 +3401,6 @@ components:
required:
- value
- color
- label
type: object
DashboardtypesTimePreference:
enum:

View File

@@ -413,21 +413,11 @@ export interface AlertmanagertypesRecurrenceDTO {
* @type string
*/
duration: string;
/**
* @type string,null
* @format date-time
*/
endTime?: string | null;
/**
* @type array,null
*/
repeatOn?: AlertmanagertypesRepeatOnDTO[] | null;
repeatType: AlertmanagertypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: string;
}
export interface AlertmanagertypesScheduleDTO {
@@ -441,7 +431,7 @@ export interface AlertmanagertypesScheduleDTO {
* @type string
* @format date-time
*/
startTime?: string;
startTime: string;
/**
* @type string
*/
@@ -3218,6 +3208,10 @@ export interface DashboardtypesPanelFormattingDTO {
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
table = 'table',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
@@ -3237,6 +3231,7 @@ export interface DashboardtypesLegendDTO {
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
@@ -3248,7 +3243,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
label: string;
label?: string;
/**
* @type string
*/
@@ -3913,10 +3908,12 @@ export enum DashboardtypesLineStyleDTO {
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}

View File

@@ -36,6 +36,7 @@ export const REACT_QUERY_KEY = {
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_TRACE_V3_FLAMEGRAPH: 'GET_TRACE_V3_FLAMEGRAPH',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',
GET_DEPLOYMENT_LIST: 'GET_DEPLOYMENT_LIST',

View File

@@ -1,8 +1,7 @@
.billingContainer {
margin-bottom: var(--spacing-20);
padding-top: 36px;
width: 90%;
margin: 0 auto;
margin: 0 auto var(--spacing-20);
.pageHeader {
margin-bottom: var(--spacing-8);

View File

@@ -1,6 +1,6 @@
.license-key-callout {
margin: var(--spacing-4) var(--spacing-6);
width: auto;
width: auto !important;
.license-key-callout__description {
display: flex;

View File

@@ -0,0 +1,41 @@
import { useQueries } from 'react-query';
import { render, screen } from 'tests/test-utils';
import GeneralSettings from '../index';
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueries: jest.fn(),
}));
const baseQueryResult = {
isError: false,
isLoading: false,
isFetching: false,
isSuccess: true,
data: undefined,
error: null,
refetch: jest.fn(),
};
describe('GeneralSettings index', () => {
it('renders fallback message when logs query fails with a non-APIError', () => {
(useQueries as jest.Mock).mockReturnValue([
{ ...baseQueryResult },
{ ...baseQueryResult },
{
...baseQueryResult,
isError: true,
isSuccess: false,
error: new TypeError(
"Cannot read properties of undefined (reading 'code')",
),
},
{ ...baseQueryResult },
]);
render(<GeneralSettings />);
expect(screen.getByText('something_went_wrong')).toBeInTheDocument();
});
});

View File

@@ -76,7 +76,9 @@ function GeneralSettings(): JSX.Element {
if (getRetentionPeriodLogsApiResponse.isError || getDisksResponse.isError) {
return (
<Typography>
{(getRetentionPeriodLogsApiResponse.error as APIError).getErrorMessage() ||
{(getRetentionPeriodLogsApiResponse.error instanceof APIError
? getRetentionPeriodLogsApiResponse.error.getErrorMessage()
: undefined) ||
getDisksResponse.data?.error ||
t('something_went_wrong')}
</Typography>

View File

@@ -796,7 +796,7 @@ export const getClusterMetricsQueryPayload = (
key: k8sDeploymentDesiredKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
aggregateOperator: 'latest',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'B',
@@ -839,7 +839,7 @@ export const getClusterMetricsQueryPayload = (
reduceTo: ReduceOperators.LAST,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
timeAggregation: 'latest',
},
],
queryFormulas: [],

View File

@@ -40,6 +40,7 @@ import { K8S_ENTITY_EVENTS_EXPRESSION_KEY, useEntityEvents } from './hooks';
import { getEntityEventsQueryPayload, isEventsKeyNotFoundError } from './utils';
import styles from './EntityEvents.module.scss';
import { useTimezone } from 'providers/Timezone';
interface EventDataType {
key: string;
@@ -167,17 +168,25 @@ function EntityEventsContent({
[events],
);
const columns: TableColumnsType<EventDataType> = [
{ title: 'Severity', dataIndex: 'severity', key: 'severity', width: 100 },
{
title: 'Timestamp',
dataIndex: 'timestamp',
width: 240,
ellipsis: true,
key: 'timestamp',
},
{ title: 'Body', dataIndex: 'body', key: 'body' },
];
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns: TableColumnsType<EventDataType> = useMemo(
() => [
{ title: 'Severity', dataIndex: 'severity', key: 'severity', width: 100 },
{
title: 'Timestamp',
dataIndex: 'timestamp',
width: 240,
ellipsis: true,
key: 'timestamp',
render: (value: string | number): string =>
formatTimezoneAdjustedTimestamp(
typeof value === 'string' ? value : value / 1e6,
),
},
{ title: 'Body', dataIndex: 'body', key: 'body' },
],
[formatTimezoneAdjustedTimestamp],
);
const handleExpandRowIcon = ({
expanded,

View File

@@ -41,6 +41,7 @@ import { getTraceListColumns } from './traceListColumns';
import { getEntityTracesQueryPayload } from './utils';
import styles from './EntityTraces.module.scss';
import { useTimezone } from 'providers/Timezone';
interface Props {
timeRange: {
@@ -136,7 +137,11 @@ function EntityTracesContent({
[timeRange.startTime, timeRange.endTime, userExpression],
);
const traceListColumns = getTraceListColumns(selectedEntityTracesColumns);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const traceListColumns = getTraceListColumns(
selectedEntityTracesColumns,
formatTimezoneAdjustedTimestamp,
);
const isKeyNotFound = isKeyNotFoundError(error);
const isDataEmpty =

View File

@@ -1,15 +1,14 @@
import { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import {
BlockLink,
getTraceLink,
} from 'container/TracesExplorer/ListView/utils';
import dayjs from 'dayjs';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FormatTimezoneAdjustedTimestamp } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
const keyToLabelMap: Record<string, string> = {
timestamp: 'Timestamp',
@@ -59,6 +58,7 @@ const getValueForKey = (data: Record<string, any>, key: string): any => {
export const getTraceListColumns = (
selectedColumns: BaseAutocompleteData[],
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp,
): ColumnsType<RowData> => {
const columns: ColumnsType<RowData> =
selectedColumns.map(({ dataType, key, type }) => ({
@@ -73,8 +73,8 @@ export const getTraceListColumns = (
if (primaryKey === 'timestamp') {
const date =
typeof value === 'string'
? dayjs(value).format(DATE_TIME_FORMATS.ISO_DATETIME_MS)
: dayjs(value / 1e6).format(DATE_TIME_FORMATS.ISO_DATETIME_MS);
? formatTimezoneAdjustedTimestamp(value)
: formatTimezoneAdjustedTimestamp(value / 1e6);
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>

View File

@@ -1366,7 +1366,7 @@ export const getPodMetricsQueryPayload = (
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'avg',
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},

View File

@@ -86,9 +86,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
},
{
id: 'capacity',
header: 'Volume Capacity',
header: 'Capacity',
accessorFn: (row): number => row.volumeCapacity,
width: { min: 220 },
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const capacity = value as number;
@@ -105,9 +105,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
},
{
id: 'usage',
header: 'Volume Utilization',
header: 'Used',
accessorFn: (row): number => row.volumeUsage,
width: { min: 220 },
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const usage = value as number;
@@ -124,9 +124,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
},
{
id: 'available',
header: 'Volume Available',
header: 'Available',
accessorFn: (row): number => row.volumeAvailable,
width: { min: 220 },
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const available = value as number;
@@ -141,4 +141,61 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
);
},
},
{
id: 'inodes',
header: 'Inodes',
accessorFn: (row): number => row.volumeInodes,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const inodes = value as number;
return (
<ValidateColumnValueWrapper
value={inodes}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes metric"
>
<TanStackTable.Text>{inodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'inodesUsed',
header: 'Inodes Used',
accessorFn: (row): number => row.volumeInodesUsed,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const inodesUsed = value as number;
return (
<ValidateColumnValueWrapper
value={inodesUsed}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes used metric"
>
<TanStackTable.Text>{inodesUsed}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'inodesFree',
header: 'Inodes Free',
accessorFn: (row): number => row.volumeInodesFree,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const inodesFree = value as number;
return (
<ValidateColumnValueWrapper
value={inodesFree}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes free metric"
>
<TanStackTable.Text>{inodesFree}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -151,6 +151,11 @@ export function PlannedDowntimeForm(
const saveHandler = useCallback(
async (values: PlannedDowntimeFormData) => {
const { startTime, timezone } = values;
if (!startTime || !timezone) {
// unreachable: required fields should always be present on submitting.
return;
}
const data: AlertmanagertypesPostablePlannedMaintenanceDTO = {
alertIds:
values.alertRuleScope === 'all'
@@ -161,9 +166,9 @@ export function PlannedDowntimeForm(
name: values.name,
scope: values.scope,
schedule: {
startTime: values.startTime?.format(),
startTime: startTime.format(),
endTime: values.endTime?.format(),
timezone: values.timezone!,
timezone,
recurrence: values.recurrence,
},
};
@@ -200,25 +205,17 @@ export function PlannedDowntimeForm(
],
);
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
const { recurrence } = values;
const recurrenceData =
!recurrence ||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
? undefined
: {
duration: recurrence.duration
? `${recurrence.duration}${durationUnit}`
: '',
startTime: values.startTime!.format(),
endTime: values.endTime?.format(),
repeatOn: recurrence.repeatOn,
repeatType: recurrence.repeatType,
};
const rec = values.recurrence;
const recurrence =
rec && rec.repeatType !== recurrenceOptions.doesNotRepeat.value
? {
duration: `${rec.duration}${durationUnit}`,
repeatOn: rec.repeatOn,
repeatType: rec.repeatType,
}
: undefined;
await saveHandler({
...values,
recurrence: recurrenceData,
});
await saveHandler({ ...values, recurrence });
};
const handleFormData = (data: Partial<PlannedDowntimeFormData>): void => {
@@ -275,9 +272,6 @@ export function PlannedDowntimeForm(
const formattedInitialValues = useMemo((): PlannedDowntimeFormData => {
const { schedule } = initialValues;
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
const initialAlertIds = initialValues.alertIds || [];
return {
@@ -285,8 +279,12 @@ export function PlannedDowntimeForm(
alertRuleScope:
isEditMode && initialAlertIds.length === 0 ? 'all' : 'specific',
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
startTime: schedule?.startTime
? dayjs(schedule.startTime).tz(schedule.timezone)
: null,
endTime: schedule?.endTime
? dayjs(schedule.endTime).tz(schedule.timezone)
: null,
recurrence: {
...schedule?.recurrence,
repeatType: !isScheduleRecurring(schedule)
@@ -297,7 +295,7 @@ export function PlannedDowntimeForm(
timezone: schedule?.timezone as string,
scope: initialValues.scope || '',
};
}, [initialValues, alertOptions]);
}, [initialValues, isEditMode, alertOptions]);
useEffect(() => {
setSelectedTags(formattedInitialValues.alertRules);
@@ -341,7 +339,7 @@ export function PlannedDowntimeForm(
const formattedEndTime = endTime.format(TIME_FORMAT);
const formattedEndDate = endTime.format(DATE_FORMAT);
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
}, [formData, recurrenceType]);
}, [formData]);
return (
<Modal

View File

@@ -142,7 +142,6 @@ export function CollapseListContent({
updated_by_name?: string;
alertOptions?: DefaultOptionType[];
}): JSX.Element {
const repeats = schedule?.recurrence;
const renderItems = (title: string, value: ReactNode): JSX.Element => (
<div className="render-item-collapse-list">
<Typography>{title}</Typography>
@@ -193,10 +192,7 @@ export function CollapseListContent({
'Timezone',
<Typography>{schedule?.timezone || '-'}</Typography>,
)}
{renderItems(
'Repeats',
<Typography>{recurrenceInfo(repeats, schedule?.timezone)}</Typography>,
)}
{renderItems('Repeats', <Typography>{recurrenceInfo(schedule)}</Typography>)}
{renderItems(
'Alerts silenced',
alertOptions?.length ? (

View File

@@ -6,7 +6,7 @@ import type {
DeleteDowntimeScheduleByIDPathParameters,
RenderErrorResponseDTO,
AlertmanagertypesPlannedMaintenanceDTO,
AlertmanagertypesRecurrenceDTO,
AlertmanagertypesScheduleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import { AxiosError } from 'axios';
@@ -66,14 +66,17 @@ export const getAlertOptionsFromIds = (
);
export const recurrenceInfo = (
recurrence?: AlertmanagertypesRecurrenceDTO | null,
timezone?: string,
schedule?: AlertmanagertypesScheduleDTO | null,
): string => {
if (!schedule) {
return 'No';
}
const { startTime, endTime, timezone, recurrence } = schedule;
if (!recurrence) {
return 'No';
}
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
const { duration, repeatOn, repeatType } = recurrence;
const formattedStartTime = startTime
? formatDateTime(startTime, timezone)
@@ -95,7 +98,7 @@ export const defaultInitialValues: Partial<AlertmanagertypesPlannedMaintenanceDT
timezone: '',
endTime: undefined,
recurrence: undefined,
startTime: undefined,
startTime: '',
},
alertIds: [],
createdAt: undefined,

View File

@@ -11,7 +11,7 @@ export const buildSchedule = (
schedule: Partial<AlertmanagertypesScheduleDTO>,
): AlertmanagertypesScheduleDTO => ({
timezone: schedule?.timezone ?? '',
startTime: schedule?.startTime,
startTime: schedule?.startTime ?? '',
endTime: schedule?.endTime,
recurrence: schedule?.recurrence,
});

View File

@@ -1135,17 +1135,9 @@
.settings-dropdown,
.help-support-dropdown {
.ant-dropdown-menu-item {
min-height: 32px;
.ant-dropdown-menu-title-content {
color: var(--l1-foreground) !important;
}
.user-settings-dropdown-logout-section {
color: var(--danger-background);
pointer-events: auto;
}
.user-settings-dropdown-logout-section {
color: var(--danger-background);
pointer-events: auto;
}
}

View File

@@ -0,0 +1,42 @@
import { getFlamegraph } from 'api/generated/services/tracedetail';
import {
SpantypesGettableFlamegraphTraceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryResult } from 'react-query';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export interface GetTraceFlamegraphV3Props {
traceId: string;
selectedSpanId?: string;
selectFields?: TelemetryFieldKey[];
enabled?: boolean;
}
const useGetTraceFlamegraphV3 = (
props: GetTraceFlamegraphV3Props,
): UseQueryResult<SpantypesGettableFlamegraphTraceDTO, unknown> =>
useQuery({
queryFn: () =>
getFlamegraph(
{ traceID: props.traceId },
{
selectedSpanId: props.selectedSpanId,
// v5 TelemetryFieldKey and the generated DTO are runtime-identical; only
// the literal-union vs enum nominal types differ
selectFields: props.selectFields as TelemetrytypesTelemetryFieldKeyDTO[],
},
).then((res) => res.data),
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V3_FLAMEGRAPH,
props.traceId,
props.selectedSpanId,
props.selectFields,
],
enabled: props.enabled,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
export default useGetTraceFlamegraphV3;

View File

@@ -22,11 +22,13 @@ interface CacheEntry {
const CACHE_SIZE_LIMIT = 1000;
const CACHE_CLEANUP_PERCENTAGE = 0.5; // Remove 50% when limit is reached
export type FormatTimezoneAdjustedTimestamp = (
input: TimestampInput,
format?: string,
) => string;
function useTimezoneFormatter({ userTimezone }: { userTimezone: Timezone }): {
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string;
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp;
} {
// Initialize cache using useMemo to persist between renders
const cache = useMemo(() => new Map<string, CacheEntry>(), []);

View File

@@ -1,210 +0,0 @@
.dashboardDescriptionContainer {
box-shadow: none;
border: none;
background: unset;
color: var(--l2-foreground);
:global(.ant-card-body) {
padding: 0px;
}
.dashboardDetails {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 16px 16px 0px 16px;
align-items: flex-start;
.leftSection {
display: flex;
align-items: center;
gap: 8px;
width: 45%;
height: 40px;
.dashboardImg {
height: 16px;
width: 16px;
}
.dashboardTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 150% */
letter-spacing: -0.08px;
max-width: 80%;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.clickableTitle {
cursor: pointer;
}
.titleEdit {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
min-width: 0;
}
.titleInput {
flex: 1;
min-width: 0;
max-width: 70%;
}
.titleEditActionButton {
--button-height: auto;
--button-padding: 4px;
flex-shrink: 0;
}
.titleSaveActionButton {
--button-border-color: var(--text-forest-700);
--button-outlined-foreground: var(--text-forest-700);
}
.publicDashboardIcon {
margin-right: 4px;
}
}
.rightSection {
display: flex;
width: 55%;
justify-content: flex-end;
flex-wrap: wrap;
align-items: center;
gap: 14px;
height: 40px;
.icons {
display: flex;
align-items: center;
width: 32px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.icons:hover {
background-color: unset;
}
}
}
.dashboardTags {
display: flex;
gap: 6px;
padding: 16px 16px 0px 16px;
flex-wrap: wrap;
.tag {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
text-align: center;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
margin-inline-end: 0px;
}
}
.dashboardDescriptionSection {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
padding: 20px 16px 0px 16px;
}
}
.dashboardSettings {
width: 191px;
height: 302px;
flex-shrink: 0;
:global(.ant-popover-inner) {
padding: 0px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
) !important;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.menuContent {
display: flex;
flex-direction: column;
section {
display: flex;
flex-direction: column;
align-items: start;
button {
display: flex;
width: 100%;
height: unset;
padding: 8px;
align-items: center;
gap: 12px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
border-top: none;
}
}
.section1,
.section2 {
border-bottom: 1px solid var(--l1-border);
}
.deleteDashboard button {
color: var(--bg-cherry-400) !important;
}
}
}
.deleteModal :global(.ant-modal-confirm-body) {
align-items: center;
}

View File

@@ -1,32 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { isEmpty } from 'lodash-es';
import styles from '../DashboardDescription.module.scss';
interface DashboardMetaProps {
tags: string[];
description: string;
}
function DashboardMeta({ tags, description }: DashboardMetaProps): JSX.Element {
return (
<>
{tags.length > 0 && (
<div className={styles.dashboardTags}>
{tags.map((tag) => (
<Badge key={tag} className={styles.tag}>
{tag}
</Badge>
))}
</div>
)}
{!isEmpty(description) && (
<section className={styles.dashboardDescriptionSection}>
{description}
</section>
)}
</>
);
}
export default DashboardMeta;

View File

@@ -1,116 +0,0 @@
import { KeyboardEvent } from 'react';
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from '../DashboardDescription.module.scss';
interface DashboardTitleProps {
title: string;
image: string;
isPublicDashboard: boolean;
isDashboardLocked: boolean;
isEditable: boolean;
isEditing: boolean;
draft: string;
onDraftChange: (value: string) => void;
onStartEdit: () => void;
onCommit: () => void;
onCancel: () => void;
}
function DashboardTitle({
title,
image,
isPublicDashboard,
isDashboardLocked,
isEditable,
isEditing,
draft,
onDraftChange,
onStartEdit,
onCommit,
onCancel,
}: DashboardTitleProps): JSX.Element {
const canEdit = isEditable && !isDashboardLocked;
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
onCommit();
} else if (event.key === 'Escape') {
onCancel();
}
};
return (
<div className={styles.leftSection}>
<img src={image} alt="dashboard-img" className={styles.dashboardImg} />
{isEditing ? (
<div className={styles.titleEdit}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.titleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
size="icon"
className={cx(styles.titleEditActionButton, styles.titleSaveActionButton)}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="destructive"
size="icon"
className={styles.titleEditActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<TooltipSimple title={title.length > 30 ? title : ''}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.clickableTitle]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} className={styles.publicDashboardIcon} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
</div>
);
}
export default DashboardTitle;

View File

@@ -0,0 +1,11 @@
.dashboardActionsContainer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12px;
}
.dashboardActionsSecondary {
display: flex;
gap: 12px;
}

View File

@@ -28,43 +28,40 @@ import { USER_ROLES } from 'types/roles';
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DashboardSettings from '../../DashboardSettings';
import SettingsDrawer from '../SettingsDrawer';
import styles from '../DashboardDescription.module.scss';
import styles from './DashboardActions.module.scss';
import { useDashboardStore } from '../../store/useDashboardStore';
interface DashboardActionsProps {
title: string;
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
isDashboardLocked: boolean;
editDashboard: boolean;
isAuthor: boolean;
addPanelPermission: boolean;
onAddPanel: () => void;
onLockToggle: () => void;
onOpenRename: () => void;
}
function DashboardActions({
title,
dashboard,
handle,
isDashboardLocked,
editDashboard,
isAuthor,
addPanelPermission,
onAddPanel,
onLockToggle,
onOpenRename,
}: DashboardActionsProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const { user } = useAppContext();
const { t } = useTranslation(['dashboard', 'common']);
const id = dashboard.id ?? '';
const title = dashboard.spec?.display?.name ?? '';
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const [state, setCopy] = useCopyToClipboard();
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(id);
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
useEffect(() => {
if (state.error) {
@@ -103,7 +100,7 @@ function DashboardActions({
const menuItems = useMemo<MenuItem[]>(() => {
const editGroup: MenuItem[] = [];
if (!isDashboardLocked && editDashboard) {
if (canEdit) {
editGroup.push({
key: 'rename',
label: 'Rename',
@@ -159,7 +156,6 @@ function DashboardActions({
);
}, [
isDashboardLocked,
editDashboard,
isAuthor,
user.role,
dashboard.createdBy,
@@ -169,58 +165,60 @@ function DashboardActions({
exportJSON,
setCopy,
dashboardDataJSON,
canEdit,
]);
return (
<div className={styles.rightSection}>
<div className={styles.dashboardActionsContainer}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<DropdownMenuSimple menu={{ items: menuItems }}>
<Button
variant="ghost"
color="secondary"
size="icon"
prefix={<Ellipsis size={14} />}
className={styles.icons}
testId="options"
/>
</DropdownMenuSimple>
{!isDashboardLocked && editDashboard && (
<>
<div className={styles.dashboardActionsSecondary}>
<DropdownMenuSimple menu={{ items: menuItems }}>
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="icon"
prefix={<Ellipsis size="md" />}
testId="options"
/>
</DropdownMenuSimple>
{canEdit && (
<>
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
{!isDashboardLocked && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
size="md"
>
Configure
New Panel
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
{!isDashboardLocked && addPanelPermission && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
size="md"
>
New Panel
</Button>
)}
)}
</div>
<ConfirmDeleteDialog
open={isDeleteOpen}
title={`Delete dashboard "${title}"?`}
description="This action cannot be undone."
title={`Delete dashboard"?`}
description={`Are you sure you want to delete this dashboard - "${title}"? This action cannot be undone.`}
isLoading={deleteDashboardMutation.isLoading}
onConfirm={handleConfirmDelete}
onClose={(): void => setIsDeleteOpen(false)}

View File

@@ -0,0 +1,61 @@
.dashboardInfo {
display: flex;
flex-direction: column;
gap: 8px;
width: 40%;
@media (min-width: 1280px) {
width: 30%;
}
}
.dashboardTitleContainer {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.dashboardImage {
flex-shrink: 0;
}
.dashboardTitle {
flex: 1;
min-width: 0;
max-width: fit-content;
color: var(--l1-foreground);
font-size: 18px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboardTitleHover {
cursor: text !important;
}
.dashboardTitleEditor {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
}
.dashboardTitleInput {
flex: 1;
min-width: 0;
}
.dashboardTitleActionButton {
flex-shrink: 0;
}
.dashboardTags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

View File

@@ -0,0 +1,141 @@
import { KeyboardEvent } from 'react';
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import styles from './DashboardInfo.module.scss';
import { useDashboardStore } from '../../store/useDashboardStore';
interface DashboardInfoProps {
title: string;
image: string;
tags: string[];
description: string;
isPublicDashboard: boolean;
isDashboardLocked: boolean;
isEditing: boolean;
draft: string;
onDraftChange: (value: string) => void;
onStartEdit: () => void;
onCommit: () => void;
onCancel: () => void;
}
function DashboardInfo({
title,
image,
tags,
description,
isPublicDashboard,
isDashboardLocked,
isEditing,
draft,
onDraftChange,
onStartEdit,
onCommit,
onCancel,
}: DashboardInfoProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const hasTags = tags.length > 0;
const hasDescription = !isEmpty(description);
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
onCommit();
} else if (event.key === 'Escape') {
onCancel();
}
};
return (
<div className={styles.dashboardInfo}>
<div className={styles.dashboardTitleContainer}>
<img src={image} alt={title} className={styles.dashboardImage} />
{isEditing ? (
<div className={styles.dashboardTitleEditor}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
color="primary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<TooltipSimple title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
</div>
{hasTags && (
<div className={styles.dashboardTags}>
{tags.map((tag) => (
<Badge key={tag} color="warning" variant="outline">
{tag}
</Badge>
))}
</div>
)}
{hasDescription && (
<Typography.Text color="muted">{description}</Typography.Text>
)}
</div>
);
}
export default DashboardInfo;

View File

@@ -0,0 +1,20 @@
.dashboardPageToolbarContainer {
position: sticky;
top: 0;
z-index: 10;
color: var(--l2-foreground);
background-color: var(--l1-background);
padding: 16px;
box-shadow: 0 2px 2px 0px var(--l2-border);
}
.dashboardPageToolbarSubContainer {
width: 100%;
}
.dashboardInfoWithActions {
display: flex;
align-items: flex-start;
justify-content: space-between;
width: 100%;
}

View File

@@ -1,6 +1,5 @@
import { useCallback, useMemo } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { Card } from 'antd';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
@@ -13,34 +12,31 @@ import type {
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardMeta from './DashboardMeta/DashboardMeta';
import DashboardTitle from './DashboardTitle/DashboardTitle';
import { useEditableTitle } from './DashboardTitle/useEditableTitle';
import DashboardInfo from './DashboardInfo/DashboardInfo';
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
import styles from './DashboardDescription.module.scss';
import styles from './DashboardPageToolbar.module.scss';
interface DashboardDescriptionProps {
interface DashboardPageToolbarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
refetch: () => void;
}
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { dashboard, handle, refetch } = props;
const id = dashboard.id;
const isDashboardLocked = !!dashboard.locked;
const title = dashboard.spec?.display?.name ?? '';
const description = dashboard.spec?.display?.description ?? '';
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
const image = dashboard.image || Base64Icons[0];
const tags = useMemo(
() =>
@@ -51,7 +47,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
);
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const { showErrorModal } = useErrorModal();
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
@@ -59,9 +54,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
const addPanelPermission = !isDashboardLocked;
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
const isPublicDashboard = false;
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
if (!id) {
@@ -110,7 +102,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
onSave: onNameSave,
});
const onEmptyWidgetHandler = useCallback((): void => {
const onAddPanel = useCallback((): void => {
void logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
@@ -118,15 +110,15 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
}, [id, setIsPanelTypeSelectionModalOpen]);
return (
<Card className={styles.dashboardDescriptionContainer}>
<DashboardHeader title={title} image={image} />
<section className={styles.dashboardDetails}>
<DashboardTitle
<section className={styles.dashboardPageToolbarContainer}>
<div className={styles.dashboardInfoWithActions}>
<DashboardInfo
title={title}
image={image}
isPublicDashboard={isPublicDashboard}
tags={tags}
description={description}
isPublicDashboard={false}
isDashboardLocked={isDashboardLocked}
isEditable={editDashboard}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}
@@ -135,20 +127,18 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
onCancel={cancel}
/>
<DashboardActions
title={title}
dashboard={dashboard}
handle={handle}
isDashboardLocked={isDashboardLocked}
editDashboard={editDashboard}
isAuthor={isAuthor}
addPanelPermission={addPanelPermission}
onAddPanel={onEmptyWidgetHandler}
onAddPanel={onAddPanel}
onLockToggle={handleLockDashboardToggle}
onOpenRename={startEdit}
/>
</section>
<DashboardMeta tags={tags} description={description} />
</Card>
</div>
</section>
);
}
export default DashboardDescription;
export default DashboardPageToolbar;

View File

@@ -1,3 +1,8 @@
.tabsContent {
padding-left: 0 !important;
padding-right: 0 !important;
}
.placeholder {
padding: 24px;
}
@@ -9,3 +14,10 @@
line-height: 1;
padding-top: 4px;
}
// shared "settings card" wrapper, used by the dashboard-info form and cross-panel sync
.settingsCard {
padding: 24px 16px;
border-radius: 3px;
border: 1px solid var(--l2-border);
}

View File

@@ -1,85 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Select/Input to @signozhq/ui
import { Col, Input, Select, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import { Base64Icons } from '../utils';
import styles from '../GeneralSettings.module.scss';
const { Option } = Select;
interface GeneralFormProps {
title: string;
description: string;
image: string;
tags: string[];
onTitleChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
onImageChange: (value: string) => void;
onTagsChange: Dispatch<SetStateAction<string[]>>;
}
function GeneralForm({
title,
description,
image,
tags,
onTitleChange,
onDescriptionChange,
onImageChange,
onTagsChange,
}: GeneralFormProps): JSX.Element {
return (
<Col className={styles.overviewSettings}>
<Space direction="vertical" className={styles.formSpace}>
<div>
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName={styles.dashboardImageInput}
value={image}
onChange={onImageChange}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={title}
onChange={(e): void => onTitleChange(e.target.value)}
/>
</section>
</div>
<div>
<Typography className={styles.dashboardName}>Description</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={description}
className={styles.descriptionTextArea}
onChange={(e): void => onDescriptionChange(e.target.value)}
/>
</div>
<div>
<Typography className={styles.dashboardName}>Tags</Typography>
<AddTags tags={tags} setTags={onTagsChange} />
</div>
</Space>
</Col>
);
}
export default GeneralForm;

View File

@@ -1,238 +0,0 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px 16px;
}
.overviewSettings {
padding: 16px;
border-radius: 3px;
border: 1px solid var(--l1-border);
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.formSpace {
width: 100%;
display: flex;
flex-direction: column;
gap: 21px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.crossPanelSyncInfoIcon {
cursor: help;
color: var(--l3-foreground);
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
line-height: 1.5;
}
.crossPanelSyncTooltipDocLink {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-background);
font-size: 12px;
margin-top: 4px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
display: flex;
align-items: center;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
display: flex;
align-items: center;
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -0,0 +1,86 @@
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
}
.crossPanelsSyncSectionTitle {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
}
.crossPanelSyncInfoIcon {
cursor: help;
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
max-width: 200px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
}
.crossPanelSyncTooltipDocLink {
color: var(--primary-background);
font-size: 12px;
margin-top: 16px;
vertical-align: middle;
// typography override
--typography-text-display: inline-flex;
align-items: center;
gap: 5px;
}
.crossPanelSyncRow {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px dashed var(--l2-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex: 1 1 80px;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}

View File

@@ -1,6 +1,5 @@
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
import { Col, Radio, Tooltip } from 'antd';
import { ExternalLink, SolidInfoCircle } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
@@ -13,7 +12,9 @@ import {
import { getAbsoluteUrl } from 'utils/basePath';
import cx from 'classnames';
import styles from '../GeneralSettings.module.scss';
import SegmentedControl from '../SegmentedControl/SegmentedControl';
import settingsStyles from '../../DashboardSettings.module.scss';
import styles from './CrossPanelSync.module.scss';
interface CrossPanelSyncProps {
dashboardId: string;
@@ -26,12 +27,15 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
useSyncTooltipFilterMode(dashboardId);
return (
<Col className={cx(styles.overviewSettings, styles.crossPanelSyncGroup)}>
<div className={cx(settingsStyles.settingsCard, styles.crossPanelSyncGroup)}>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
<Typography.Text className={styles.crossPanelsSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<Tooltip
<TooltipSimple
side="top"
withPortal={false}
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
@@ -40,7 +44,7 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<a
<Typography.Link
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
@@ -48,15 +52,14 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
>
Learn more
<ExternalLink size={12} />
</a>
</Typography.Link>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</TooltipSimple>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
@@ -66,19 +69,18 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<Radio.Group
<SegmentedControl
testId="cursor-sync-mode"
value={cursorSyncMode}
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
onChange={setCursorSyncMode}
options={[
{ label: 'No Sync', value: DashboardCursorSync.None },
{ label: 'Crosshair', value: DashboardCursorSync.Crosshair },
{ label: 'Tooltip', value: DashboardCursorSync.Tooltip },
]}
/>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
@@ -90,24 +92,25 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
matching ones highlighted
</Typography.Text>
</div>
<Radio.Group
<SegmentedControl
testId="sync-tooltip-filter-mode"
value={syncTooltipFilterMode}
onChange={(e): void => {
onChange={(value): void => {
void logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: e.target.value,
mode: value,
});
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
setSyncTooltipFilterMode(value);
}}
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
options={[
{ label: 'All', value: SyncTooltipFilterMode.All },
{ label: 'Filtered', value: SyncTooltipFilterMode.Filtered },
]}
/>
</div>
)}
</Col>
</div>
);
}

View File

@@ -0,0 +1,80 @@
.formSpace {
display: flex;
flex-direction: column;
gap: 20px;
}
.infoItemContainer {
display: flex;
flex-direction: column;
gap: 4px;
}
.infoTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
}
.nameIconInput {
display: flex;
gap: 4px;
[data-radix-popper-content-wrapper] {
z-index: 1100 !important;
}
}
.dashboardImageInput {
display: flex;
width: 32px;
min-width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
background: var(--l3-background);
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
svg {
display: none;
}
}
.dashboardImageOptions {
min-width: min-content;
}
.dashboardImageSelectItem {
width: min-content;
span {
vertical-align: middle;
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l2-border);
}
.descriptionTextArea {
border-radius: 2px;
border: 1px solid var(--l2-border);
}
// the V1 tags input ships borderless; give the field a visible box to match
.tagsField {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 2px;
border: 1px solid var(--l2-border);
// background: var(--l3-background);
}

View File

@@ -0,0 +1,101 @@
import { Dispatch, SetStateAction } from 'react';
import { Input } from '@signozhq/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
import { Input as AntdInput } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import { Base64Icons } from '../utils';
import settingsStyles from '../../DashboardSettings.module.scss';
import styles from './DashboardInfoForm.module.scss';
interface DashboardInfoFormProps {
title: string;
description: string;
image: string;
tags: string[];
onTitleChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
onImageChange: (value: string) => void;
onTagsChange: Dispatch<SetStateAction<string[]>>;
}
function DashboardInfoForm({
title,
description,
image,
tags,
onTitleChange,
onDescriptionChange,
onImageChange,
onTagsChange,
}: DashboardInfoFormProps): JSX.Element {
return (
<div className={settingsStyles.settingsCard}>
<div className={styles.formSpace}>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
value={image}
onChange={(value): void => onImageChange(value as string)}
>
<SelectTrigger className={styles.dashboardImageInput} />
<SelectContent
className={styles.dashboardImageOptions}
withPortal={false}
>
{Base64Icons.map((icon) => (
<SelectItem
key={icon}
value={icon}
className={styles.dashboardImageSelectItem}
>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</SelectItem>
))}
</SelectContent>
</Select>
<Input
testId="dashboard-name"
className={styles.dashboardNameInput}
value={title}
onChange={(e): void => onTitleChange(e.target.value)}
/>
</section>
</div>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Description</Typography>
<AntdInput.TextArea
data-testid="dashboard-desc"
rows={6}
value={description}
className={styles.descriptionTextArea}
onChange={(e): void => onDescriptionChange(e.target.value)}
/>
</div>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Tags</Typography>
<div className={styles.tagsField}>
<AddTags tags={tags} setTags={onTagsChange} />
</div>
</div>
</div>
</div>
);
}
export default DashboardInfoForm;

View File

@@ -0,0 +1,5 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 24px;
}

View File

@@ -0,0 +1,61 @@
.segmented {
// override RadioGroup's default vertical grid; lay segments out connected
display: inline-flex;
grid-auto-flow: column;
gap: 0;
flex-shrink: 0;
border: 1px solid var(--l2-border);
border-radius: 2px;
}
.segment {
position: relative;
display: flex;
align-items: center;
&:not(:last-child) {
border-right: 1px solid var(--l2-border);
}
// the visible segment is the radio's label (htmlFor-wired, so clicks register)
label {
display: flex;
align-items: center;
min-height: 24px;
padding: 6px 14px;
font-family: Inter;
font-size: 13px;
line-height: 20px;
color: var(--l2-foreground);
white-space: nowrap;
cursor: pointer;
user-select: none;
}
}
// collapse the radio circle into a transparent full-cell click target
.segmentInput {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
// hide the default radio dot/indicator
* {
display: none;
}
}
// highlight the selected segment as a raised, lighter pill (data-state is a
// stable Radix attribute). --l3-background is the lightest layer, so lift it
// further with a subtle foreground tint rather than going darker.
.segmentInput[data-state='checked'] + label {
background: var(--l3-background);
color: var(--l1-foreground);
font-weight: 500;
}

View File

@@ -0,0 +1,51 @@
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import styles from './SegmentedControl.module.scss';
export interface SegmentedControlOption<T extends string> {
label: string;
value: T;
}
interface SegmentedControlProps<T extends string> {
value: T;
options: SegmentedControlOption<T>[];
onChange: (value: T) => void;
testId?: string;
}
/**
* Connected pill segmented control composed on top of @signozhq/ui RadioGroup:
* the radio circle is collapsed into a transparent full-cell click target and
* the label becomes the visible segment (highlighted via the radio's stable
* `data-state="checked"`). Keeps radio semantics + keyboard nav.
*/
function SegmentedControl<T extends string>({
value,
options,
onChange,
testId,
}: SegmentedControlProps<T>): JSX.Element {
return (
<RadioGroup
className={styles.segmented}
value={value}
onChange={(next): void => onChange(next as T)}
testId={testId}
>
{options.map((option) => (
<RadioGroupItem
key={option.value}
value={option.value}
containerClassName={styles.segment}
className={styles.segmentInput}
testId={testId ? `${testId}-${option.value}` : undefined}
>
{option.label}
</RadioGroupItem>
))}
</RadioGroup>
);
}
export default SegmentedControl;

View File

@@ -0,0 +1,39 @@
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 16px;
position: fixed;
bottom: 0;
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionButtons {
display: flex;
gap: 8px;
}

View File

@@ -3,7 +3,7 @@ import { Button } from '@signozhq/ui/button';
import { Check, X } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from '../GeneralSettings.module.scss';
import styles from './UnsavedChangesFooter.module.scss';
interface UnsavedChangesFooterProps {
count: number;
@@ -29,13 +29,13 @@ function UnsavedChangesFooter({
{count > 1 && 's'}
</Typography.Text>
</div>
<div className={styles.footerActionBtns}>
<div className={styles.footerActionButtons}>
<Button
variant="ghost"
color="secondary"
disabled={isSaving}
prefix={<X size={14} />}
onClick={onDiscard}
className={styles.discardBtn}
>
Discard
</Button>
@@ -47,7 +47,6 @@ function UnsavedChangesFooter({
prefix={<Check size={14} />}
testId="save-dashboard-config"
onClick={onSave}
className={styles.saveBtn}
>
{t('save')}
</Button>

View File

@@ -11,22 +11,22 @@ import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
import GeneralForm from './GeneralForm/GeneralForm';
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
import { Base64Icons, stringsToTags, tagsToStrings } from './utils';
import styles from './GeneralSettings.module.scss';
import styles from './Overview.module.scss';
interface GeneralSettingsProps {
interface OverviewProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
function Overview({ dashboard }: OverviewProps): JSX.Element {
const id = dashboard.id;
const refetch = useDashboardStore((s) => s.refetch);
const title = dashboard.spec?.display?.name ?? '';
const description = dashboard.spec?.display?.description ?? '';
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
const image = dashboard.image || Base64Icons[0];
const tagsAsStrings = useMemo(
() => tagsToStrings(dashboard.tags ?? []),
@@ -64,7 +64,7 @@ function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
value,
});
if (updatedTitle !== title) {
if (updatedTitle !== title && updatedTitle !== '') {
ops.push(replace('/spec/display/name', updatedTitle));
}
if (updatedDescription !== description) {
@@ -89,9 +89,6 @@ function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
]);
const onSaveHandler = useCallback(async (): Promise<void> => {
if (!id) {
return;
}
const ops = buildPatch();
if (ops.length === 0) {
return;
@@ -110,7 +107,7 @@ function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
}, [id, buildPatch, refetch, showErrorModal]);
useEffect(() => {
let n = 0;
let numberOfUnsavedChanges = 0;
const initialValues = [title, description, tagsAsStrings, image];
const updatedValues = [
updatedTitle,
@@ -120,10 +117,10 @@ function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
];
initialValues.forEach((val, index) => {
if (!isEqual(val, updatedValues[index])) {
n += 1;
numberOfUnsavedChanges += 1;
}
});
setNumberOfUnsavedChanges(n);
setNumberOfUnsavedChanges(numberOfUnsavedChanges);
}, [
description,
image,
@@ -144,7 +141,7 @@ function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
return (
<div className={styles.overviewContent}>
<GeneralForm
<DashboardInfoForm
title={updatedTitle}
description={updatedDescription}
image={updatedImage}
@@ -167,4 +164,4 @@ function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
);
}
export default GeneralSettings;
export default Overview;

View File

@@ -0,0 +1,106 @@
// settings card wrapper — mirrors the V1 public dashboard treatment
.publicDashboardCard {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border-radius: 3px;
border: 1px solid var(--l2-border);
}
.statusTitle {
margin-bottom: 16px;
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.checkbox {
margin-bottom: 8px;
}
.timeRangeSelectGroup {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.timeRangeSelectLabel {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.timeRangeSelect {
width: 200px;
}
.urlGroup {
display: flex;
flex-direction: column;
gap: 4px;
}
.urlLabel {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.urlContainer {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.urlText {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
line-height: 32px;
}
.callout {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 12px 8px;
border-radius: 3px;
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
}
.calloutIcon {
flex-shrink: 0;
color: var(--text-robin-300);
}
.calloutText {
color: var(--text-robin-300);
font-family: Inter;
font-size: 11px;
font-weight: 400;
line-height: 16px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 32px;
}

View File

@@ -0,0 +1,71 @@
import { Globe, Trash } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from './PublicDashboard.module.scss';
interface PublicDashboardActionsProps {
isPublic: boolean;
disabled: boolean;
isPublishing: boolean;
isUpdating: boolean;
isUnpublishing: boolean;
onPublish: () => void;
onUpdate: () => void;
onUnpublish: () => void;
}
function PublicDashboardActions({
isPublic,
disabled,
isPublishing,
isUpdating,
isUnpublishing,
onPublish,
onUpdate,
onUnpublish,
}: PublicDashboardActionsProps): JSX.Element {
return (
<div className={styles.actions}>
{isPublic ? (
<>
<Button
variant="outlined"
color="destructive"
disabled={disabled}
loading={isUnpublishing}
prefix={<Trash size={14} />}
testId="public-dashboard-unpublish"
onClick={onUnpublish}
>
Unpublish dashboard
</Button>
<Button
variant="solid"
color="primary"
disabled={disabled}
loading={isUpdating}
prefix={<Globe size={14} />}
testId="public-dashboard-update"
onClick={onUpdate}
>
Update published dashboard
</Button>
</>
) : (
<Button
variant="solid"
color="primary"
disabled={disabled}
loading={isPublishing}
prefix={<Globe size={14} />}
testId="public-dashboard-publish"
onClick={onPublish}
>
Publish dashboard
</Button>
)}
</div>
);
}
export default PublicDashboardActions;

View File

@@ -0,0 +1,17 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from './PublicDashboard.module.scss';
function PublicDashboardCallout(): JSX.Element {
return (
<div className={styles.callout}>
<Info size={12} className={styles.calloutIcon} />
<Typography.Text className={styles.calloutText}>
Dashboard variables won&apos;t work in public dashboards
</Typography.Text>
</div>
);
}
export default PublicDashboardCallout;

View File

@@ -0,0 +1,54 @@
import { Checkbox } from '@signozhq/ui/checkbox';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { TIME_RANGE_PRESETS_OPTIONS } from './constants';
import styles from './PublicDashboard.module.scss';
interface PublicDashboardSettingsFormProps {
timeRangeEnabled: boolean;
defaultTimeRange: string;
disabled: boolean;
onTimeRangeEnabledChange: (value: boolean) => void;
onDefaultTimeRangeChange: (value: string) => void;
}
function PublicDashboardSettingsForm({
timeRangeEnabled,
defaultTimeRange,
disabled,
onTimeRangeEnabledChange,
onDefaultTimeRangeChange,
}: PublicDashboardSettingsFormProps): JSX.Element {
return (
<>
<Checkbox
id="public-dashboard-enable-time-range"
className={styles.checkbox}
testId="public-dashboard-time-range-toggle"
value={timeRangeEnabled}
disabled={disabled}
onChange={(checked): void => onTimeRangeEnabledChange(checked === true)}
>
Enable time range
</Checkbox>
<div className={styles.timeRangeSelectGroup}>
<Typography.Text className={styles.timeRangeSelectLabel}>
Default time range
</Typography.Text>
<SelectSimple
className={styles.timeRangeSelect}
testId="public-dashboard-default-time-range"
placeholder="Select default time range"
items={TIME_RANGE_PRESETS_OPTIONS}
value={defaultTimeRange}
disabled={disabled}
onChange={(value): void => onDefaultTimeRangeChange(value as string)}
/>
</div>
</>
);
}
export default PublicDashboardSettingsForm;

View File

@@ -0,0 +1,21 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './PublicDashboard.module.scss';
interface PublicDashboardStatusProps {
isPublic: boolean;
}
function PublicDashboardStatus({
isPublic,
}: PublicDashboardStatusProps): JSX.Element {
return (
<Typography.Text className={styles.statusTitle}>
{isPublic
? 'This dashboard is publicly accessible. Anyone with the link can view it.'
: 'This dashboard is private. Publish it to make it accessible to anyone with the link.'}
</Typography.Text>
);
}
export default PublicDashboardStatus;

View File

@@ -0,0 +1,49 @@
import { Copy, ExternalLink } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './PublicDashboard.module.scss';
interface PublicDashboardUrlProps {
url: string;
onCopy: () => void;
onOpen: () => void;
}
function PublicDashboardUrl({
url,
onCopy,
onOpen,
}: PublicDashboardUrlProps): JSX.Element {
return (
<div className={styles.urlGroup}>
<Typography.Text className={styles.urlLabel}>
Public dashboard URL
</Typography.Text>
<div className={styles.urlContainer}>
<Typography.Text className={styles.urlText}>{url}</Typography.Text>
<Button
variant="ghost"
size="icon"
aria-label="Copy public dashboard URL"
testId="public-dashboard-copy-url"
onClick={onCopy}
>
<Copy size={14} />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Open public dashboard in new tab"
testId="public-dashboard-open-url"
onClick={onOpen}
>
<ExternalLink size={14} />
</Button>
</div>
</div>
);
}
export default PublicDashboardUrl;

View File

@@ -0,0 +1,14 @@
export interface TimeRangePresetOption {
label: string;
value: string;
}
// Default time-range presets offered for the public dashboard viewer.
export const TIME_RANGE_PRESETS_OPTIONS: TimeRangePresetOption[] = [
{ label: 'Last 5 minutes', value: '5m' },
{ label: 'Last 15 minutes', value: '15m' },
{ label: 'Last 30 minutes', value: '30m' },
{ label: 'Last 1 hour', value: '1h' },
{ label: 'Last 6 hours', value: '6h' },
{ label: 'Last 1 day', value: '24h' },
];

View File

@@ -0,0 +1,71 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import PublicDashboardActions from './PublicDashboardActions';
import PublicDashboardCallout from './PublicDashboardCallout';
import PublicDashboardSettingsForm from './PublicDashboardSettingsForm';
import PublicDashboardStatus from './PublicDashboardStatus';
import PublicDashboardUrl from './PublicDashboardUrl';
import { usePublicDashboard } from './usePublicDashboard';
import styles from './PublicDashboard.module.scss';
interface PublicDashboardSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function PublicDashboardSettings({
dashboard,
}: PublicDashboardSettingsProps): JSX.Element {
const {
isPublic,
isAdmin,
isLoading,
isPublishing,
isUpdating,
isUnpublishing,
timeRangeEnabled,
defaultTimeRange,
publicUrl,
setTimeRangeEnabled,
setDefaultTimeRange,
onPublish,
onUpdate,
onUnpublish,
onCopyUrl,
onOpenUrl,
} = usePublicDashboard(dashboard.id);
const controlsDisabled = isLoading || !isAdmin;
return (
<div className={styles.publicDashboardCard}>
<PublicDashboardStatus isPublic={isPublic} />
<PublicDashboardSettingsForm
timeRangeEnabled={timeRangeEnabled}
defaultTimeRange={defaultTimeRange}
disabled={controlsDisabled}
onTimeRangeEnabledChange={setTimeRangeEnabled}
onDefaultTimeRangeChange={setDefaultTimeRange}
/>
{isPublic && (
<PublicDashboardUrl url={publicUrl} onCopy={onCopyUrl} onOpen={onOpenUrl} />
)}
<PublicDashboardCallout />
<PublicDashboardActions
isPublic={isPublic}
disabled={controlsDisabled}
isPublishing={isPublishing}
isUpdating={isUpdating}
isUnpublishing={isUnpublishing}
onPublish={onPublish}
onUpdate={onUpdate}
onUnpublish={onUnpublish}
/>
</div>
);
}
export default PublicDashboardSettings;

View File

@@ -0,0 +1,197 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateGetPublicDashboard,
useCreatePublicDashboard,
useDeletePublicDashboard,
useGetPublicDashboard,
useUpdatePublicDashboard,
} from 'api/generated/services/dashboard';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
export interface UsePublicDashboardReturn {
isPublic: boolean;
isAdmin: boolean;
isLoading: boolean;
isPublishing: boolean;
isUpdating: boolean;
isUnpublishing: boolean;
timeRangeEnabled: boolean;
defaultTimeRange: string;
publicUrl: string;
setTimeRangeEnabled: (value: boolean) => void;
setDefaultTimeRange: (value: string) => void;
onPublish: () => void;
onUpdate: () => void;
onUnpublish: () => void;
onCopyUrl: () => void;
onOpenUrl: () => void;
}
/**
* Encapsulates the public-dashboard query, the create/update/delete mutations and the
* local form state for the V2 publish settings section. Targets the same
* `/dashboards/{id}/public` endpoint as V1 via the generated client.
*/
export function usePublicDashboard(
dashboardId: string,
): UsePublicDashboardReturn {
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const { user } = useAppContext();
const isAdmin = user?.role === USER_ROLES.ADMIN;
const [, copyToClipboard] = useCopyToClipboard();
const [timeRangeEnabled, setTimeRangeEnabled] = useState<boolean>(true);
const [defaultTimeRange, setDefaultTimeRange] =
useState<string>(DEFAULT_TIME_RANGE);
const {
data,
isLoading: isLoadingMeta,
isFetching,
error,
refetch,
} = useGetPublicDashboard(
{ id: dashboardId },
{ query: { enabled: !!dashboardId, retry: false } },
);
// react-query retains the last successful `data` even after a refetch errors, so
// after unpublishing (the refetch 404s) `data` still holds the old publicPath.
// Gate on `!error` so the UI flips back to the private state.
const publicMeta = error ? undefined : data?.data;
const isPublic = !!publicMeta?.publicPath;
// Seed form state from the server config when published.
useEffect(() => {
if (publicMeta) {
setTimeRangeEnabled(publicMeta.timeRangeEnabled ?? false);
setDefaultTimeRange(publicMeta.defaultTimeRange || DEFAULT_TIME_RANGE);
}
}, [publicMeta]);
// A 404 (dashboard not published) surfaces as an error — reset to defaults.
useEffect(() => {
if (error) {
setTimeRangeEnabled(true);
setDefaultTimeRange(DEFAULT_TIME_RANGE);
}
}, [error]);
const publicUrl = useMemo(
() => getAbsoluteUrl(publicMeta?.publicPath ?? ''),
[publicMeta?.publicPath],
);
const handleError = useCallback(
(err: unknown): void => {
showErrorModal(err as APIError);
},
[showErrorModal],
);
const handleSuccess = useCallback(
(message: string): void => {
toast.success(message);
void invalidateGetPublicDashboard(queryClient, { id: dashboardId });
void refetch();
},
[queryClient, dashboardId, refetch],
);
const { mutate: createPublicDashboard, isLoading: isPublishing } =
useCreatePublicDashboard({
mutation: {
onSuccess: () => handleSuccess('Dashboard published successfully'),
onError: handleError,
},
});
const { mutate: updatePublicDashboard, isLoading: isUpdating } =
useUpdatePublicDashboard({
mutation: {
onSuccess: () => handleSuccess('Public dashboard updated successfully'),
onError: handleError,
},
});
const { mutate: deletePublicDashboard, isLoading: isUnpublishing } =
useDeletePublicDashboard({
mutation: {
onSuccess: () => handleSuccess('Dashboard unpublished successfully'),
onError: handleError,
},
});
const onPublish = useCallback((): void => {
if (!dashboardId) {
return;
}
createPublicDashboard({
pathParams: { id: dashboardId },
data: { timeRangeEnabled, defaultTimeRange },
});
}, [createPublicDashboard, dashboardId, timeRangeEnabled, defaultTimeRange]);
const onUpdate = useCallback((): void => {
if (!dashboardId) {
return;
}
updatePublicDashboard({
pathParams: { id: dashboardId },
data: { timeRangeEnabled, defaultTimeRange },
});
}, [updatePublicDashboard, dashboardId, timeRangeEnabled, defaultTimeRange]);
const onUnpublish = useCallback((): void => {
if (!dashboardId) {
return;
}
deletePublicDashboard({ pathParams: { id: dashboardId } });
}, [deletePublicDashboard, dashboardId]);
const onCopyUrl = useCallback((): void => {
if (!publicUrl) {
return;
}
copyToClipboard(publicUrl);
toast.success('Copied public dashboard URL successfully');
}, [copyToClipboard, publicUrl]);
const onOpenUrl = useCallback((): void => {
if (publicUrl) {
openInNewTab(publicUrl);
}
}, [publicUrl]);
const isLoading =
isLoadingMeta || isFetching || isPublishing || isUpdating || isUnpublishing;
return {
isPublic,
isAdmin,
isLoading,
isPublishing,
isUpdating,
isUnpublishing,
timeRangeEnabled,
defaultTimeRange,
publicUrl,
setTimeRangeEnabled,
setDefaultTimeRange,
onPublish,
onUpdate,
onUnpublish,
onCopyUrl,
onOpenUrl,
};
}

View File

@@ -0,0 +1,103 @@
import { useEffect, useMemo, useState } from 'react';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
import { Select } from 'antd';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import useDebounce from 'hooks/useDebounce';
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
import styles from './VariableForm.module.scss';
interface DynamicVariableFieldsProps {
attribute: string;
signal: TelemetrySignal;
onChange: (patch: {
dynamicAttribute?: string;
dynamicSignal?: TelemetrySignal;
}) => void;
onPreview: (values: (string | number)[]) => void;
}
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
function DynamicVariableFields({
attribute,
signal,
onChange,
onPreview,
}: DynamicVariableFieldsProps): JSX.Element {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const { data: keyData, isLoading } = useGetFieldKeys({
signal,
name: debouncedSearch || undefined,
});
// `keys` is a Record keyed BY field name; the field names are the map keys.
// When the API reports the list is `complete`, search filters locally.
const isComplete = keyData?.data?.complete === true;
const options = useMemo(
() =>
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
label: name,
value: name,
})),
[keyData],
);
const { data: valueData } = useGetFieldValues({
signal,
name: attribute,
enabled: !!attribute,
});
useEffect(() => {
const payload = valueData?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
onPreview(values);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [valueData]);
return (
<>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Source</Typography.Text>
</div>
<SelectSimple
className={styles.sortSelect}
value={signal}
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
onChange={(value): void =>
onChange({ dynamicSignal: value as TelemetrySignal })
}
testId="variable-signal-select"
/>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Attribute</Typography.Text>
</div>
<Select
className={styles.searchSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
loading={isLoading}
filterOption={isComplete}
onSearch={setSearch}
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
options={options}
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
data-testid="variable-field-select"
/>
</div>
</>
);
}
export default DynamicVariableFields;

View File

@@ -0,0 +1,93 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import Editor from 'components/Editor';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { VariableSort } from '../variableModel';
import styles from './VariableForm.module.scss';
interface QueryVariableFieldsProps {
queryValue: string;
sort: VariableSort;
onChange: (queryValue: string) => void;
onPreview: (values: (string | number)[]) => void;
onError: (message: string | null) => void;
}
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
function QueryVariableFields({
queryValue,
sort,
onChange,
onPreview,
onError,
}: QueryVariableFieldsProps): JSX.Element {
const [isRunning, setIsRunning] = useState(false);
const runTest = async (): Promise<void> => {
setIsRunning(true);
onError(null);
try {
const res = await dashboardVariablesQuery({
query: queryValue,
variables: {},
});
if (res.statusCode === 200 && res.payload) {
onPreview(
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
);
} else {
onError(res.error || 'Failed to run query');
onPreview([]);
}
} catch (err) {
onError((err as Error).message || 'Failed to run query');
onPreview([]);
} finally {
setIsRunning(false);
}
};
return (
<div className={styles.queryContainer}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Query</Typography.Text>
</div>
<div className={styles.editorWrap}>
<Editor
language="sql"
value={queryValue}
onChange={(value): void => onChange(value)}
height="240px"
options={{
fontSize: 13,
wordWrap: 'on',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
minimap: { enabled: false },
}}
/>
</div>
<div className={styles.testRow}>
<Button
variant="solid"
color="primary"
size="sm"
loading={isRunning}
disabled={!queryValue}
onClick={runTest}
testId="variable-test-run"
>
Test Run Query
</Button>
</div>
</div>
);
}
export default QueryVariableFields;

View File

@@ -0,0 +1,310 @@
/* Faithful reproduction of the V1 VariableItem layout, scoped as a module and
built on @signozhq components where possible. antd is retained only for the
monaco Editor, multiline TextArea, Collapse, and searchable Selects. */
.container {
display: flex;
flex-direction: column;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.allVariables {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--l1-border);
}
.allVariablesBtn {
--button-height: 24px;
--button-padding: 0;
color: var(--muted-foreground);
}
.content {
display: flex;
flex-direction: column;
gap: 20px;
padding: 12px 16px 20px;
}
/* VariableItemRow */
.row {
display: flex;
gap: 1rem;
margin-bottom: 0;
}
/* LabelContainer */
.labelContainer {
width: 200px;
}
.label {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.column {
flex-direction: column;
gap: 8px;
}
.input,
.textarea,
.defaultInput {
padding: 6px 6px 6px 8px;
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l3-background);
}
.input,
.textarea {
width: 100%;
}
.defaultInput {
width: 342px;
}
.errorText {
font-size: 12px;
color: var(--bg-amber-500);
}
/* Variable type segmented group */
.typeSection {
align-items: center;
justify-content: space-between;
}
.typeLabelContainer {
display: flex;
align-items: center;
gap: 8px;
width: auto;
}
.typeBtnGroup {
display: grid;
grid-template-columns: repeat(4, max-content);
height: 32px;
flex-shrink: 0;
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l2-background);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
}
.typeBtn {
--button-height: 32px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 114px;
border-radius: 0;
color: var(--l2-foreground);
& + & {
border-left: 1px solid var(--l1-border);
}
}
.typeBtnSelected {
background: var(--l1-border);
color: var(--l1-foreground);
}
.betaTag {
margin-left: 4px;
}
/* Query */
.queryContainer {
display: flex;
flex-flow: column wrap;
gap: 1rem;
min-width: 0;
margin-bottom: 0;
}
.editorWrap {
height: 240px;
overflow: hidden;
border: 1px solid var(--l1-border);
border-radius: 2px;
}
.testRow {
display: flex;
margin-top: 8px;
}
/* Custom — antd Collapse */
.customSection {
margin-bottom: 0;
}
.customSection :global(.custom-collapse) {
width: 100%;
border: 1px solid var(--l1-border);
border-radius: 3px 3px 0 0;
:global(.ant-collapse-item) {
border-bottom: none;
}
:global(.ant-collapse-header) {
align-items: center;
gap: 8px;
height: 38px;
padding: 12px;
background: var(--l3-background);
border-radius: 3px 3px 0 0;
}
:global(.ant-collapse-header-text) {
display: flex;
align-items: center;
gap: 10px;
padding: 1px 2px;
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
line-height: 18px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
:global(.ant-collapse-content-box) {
padding: 0;
}
:global(.comma-input) {
height: 109px;
border: none;
}
}
/* Textbox */
.textboxSection {
align-items: center;
justify-content: space-between;
margin-bottom: 0;
}
/* Preview strip */
.previewSection {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 88px;
margin-bottom: 0;
padding-bottom: 8px;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.previewLabel {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 4px 8px;
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
line-height: 18px;
border-radius: 3px 0 2px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
.previewValues {
display: flex;
flex-flow: wrap;
gap: 8px;
padding: 4.5px 11px;
overflow-y: auto;
}
.previewValues [data-slot='badge'] {
height: 30px;
align-items: center;
color: var(--l1-foreground);
font-family: 'Space Mono';
font-size: 14px;
border: 1px solid var(--l1-border);
border-radius: 2px;
}
.previewError {
color: var(--bg-amber-500);
}
/* Sort / multi / all / default rows */
.sortSection,
.multiSection,
.allOptionSection,
.dynamicSection {
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0;
}
.sortSection {
align-items: center;
}
.rowLabel {
width: 339px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.sortSelect {
width: 192px;
}
.defaultValueSection {
display: grid;
grid-template-columns: max-content 1fr;
gap: 1rem;
align-items: center;
margin-bottom: 0;
}
.defaultValueSection .label {
display: block;
margin-bottom: 2px;
}
.defaultValueDesc {
display: block;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
line-height: 18px;
letter-spacing: -0.06px;
}
.searchSelect {
width: 100%;
}
/* Footer */
.footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 12px;
}

View File

@@ -0,0 +1,351 @@
import { useEffect, useState } from 'react';
import { ArrowLeft, Check, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse/searchable Select: no @signozhq/ui equivalent
import { Collapse, Input as AntdInput, Select } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import {
VARIABLE_SORTS,
type VariableFormModel,
type VariableSort,
type VariableType,
} from '../variableModel';
import DynamicVariableFields from './DynamicVariableFields';
import QueryVariableFields from './QueryVariableFields';
import VariableTypeSelector from './VariableTypeSelector';
import styles from './VariableForm.module.scss';
const SORT_LABEL: Record<VariableSort, string> = {
DISABLED: 'Disabled',
ASC: 'Ascending',
DESC: 'Descending',
};
function getNameError(name: string, existingNames: string[]): string | null {
if (name === '') {
return 'Variable name is required';
}
if (/\s/.test(name)) {
return 'Variable name cannot contain whitespaces';
}
if (existingNames.includes(name)) {
return 'Variable name already exists';
}
return null;
}
interface VariableFormProps {
initial: VariableFormModel;
/** Names of the other variables, for uniqueness validation. */
existingNames: string[];
isSaving: boolean;
onClose: () => void;
onSave: (model: VariableFormModel) => void;
}
/**
* In-drawer variable editor reproducing the V1 VariableItem layout, built on
* @signozhq components (antd kept only for the monaco editor, TextArea, Collapse
* and searchable selects). Master→detail: renders in place of the list.
*/
function VariableForm({
initial,
existingNames,
isSaving,
onClose,
onSave,
}: VariableFormProps): JSX.Element {
const [model, setModel] = useState<VariableFormModel>(initial);
const [previewValues, setPreviewValues] = useState<(string | number)[]>([]);
const [previewError, setPreviewError] = useState<string | null>(null);
const [defaultValue, setDefaultValue] = useState<string>(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
useEffect(() => {
setModel(initial);
setPreviewValues([]);
setPreviewError(null);
setDefaultValue(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
}, [initial]);
const set = (patch: Partial<VariableFormModel>): void =>
setModel((prev) => ({ ...prev, ...patch }));
const selectType = (type: VariableType): void => {
set({ type });
setPreviewValues([]);
setPreviewError(null);
};
const onCustomChange = (value: string): void => {
set({ customValue: value });
setPreviewValues(
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
);
};
const trimmedName = model.name.trim();
const nameError = getNameError(trimmedName, existingNames);
const isListType =
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
const handleSave = (): void => {
onSave({
...model,
name: trimmedName,
defaultValue: defaultValue ? { value: defaultValue } : undefined,
});
};
return (
<>
<div className={styles.container}>
<div className={styles.allVariables}>
<Button
variant="ghost"
color="secondary"
className={styles.allVariablesBtn}
prefix={<ArrowLeft size={14} />}
onClick={onClose}
testId="variable-form-back"
>
All variables
</Button>
</div>
<div className={styles.content}>
{/* Name */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Name</Typography.Text>
<Input
className={styles.input}
value={model.name}
placeholder="Unique name of the variable"
onChange={(e): void => set({ name: e.target.value })}
testId="variable-name-input"
/>
{nameError ? (
<Typography.Text className={styles.errorText}>
{nameError}
</Typography.Text>
) : null}
</div>
{/* Description */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Description</Typography.Text>
<AntdInput.TextArea
className={styles.textarea}
value={model.description}
placeholder="Enter a description for the variable"
rows={3}
onChange={(e): void => set({ description: e.target.value })}
data-testid="variable-description-input"
/>
</div>
{/* Variable Type */}
<VariableTypeSelector value={model.type} onChange={selectType} />
{/* Type-specific body */}
{model.type === 'DYNAMIC' ? (
<DynamicVariableFields
attribute={model.dynamicAttribute}
signal={model.dynamicSignal}
onChange={(patch): void => set(patch)}
onPreview={setPreviewValues}
/>
) : null}
{model.type === 'QUERY' ? (
<QueryVariableFields
queryValue={model.queryValue}
sort={model.sort}
onChange={(queryValue): void => set({ queryValue })}
onPreview={setPreviewValues}
onError={setPreviewError}
/>
) : null}
{model.type === 'CUSTOM' ? (
<div className={cx(styles.row, styles.customSection)}>
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<AntdInput.TextArea
value={model.customValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => onCustomChange(e.target.value)}
data-testid="variable-custom-input"
/>
),
},
]}
/>
</div>
) : null}
{model.type === 'TEXT' ? (
<div className={cx(styles.row, styles.textboxSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
</div>
<Input
className={styles.defaultInput}
value={model.textValue}
placeholder="Enter a default value (if any)..."
onChange={(e): void => set({ textValue: e.target.value })}
testId="variable-text-input"
/>
</div>
) : null}
{/* Shared rows for list-type variables */}
{isListType ? (
<>
<div className={cx(styles.row, styles.previewSection)}>
<Typography.Text className={styles.previewLabel}>
Preview of Values
</Typography.Text>
<div className={styles.previewValues}>
{previewError ? (
<Typography.Text className={styles.previewError}>
{previewError}
</Typography.Text>
) : (
previewValues.map((value, idx) => (
<Badge
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
key={`${value}-${idx}`}
color="vanilla"
>
{value.toString()}
</Badge>
))
)}
</div>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
</div>
<SelectSimple
className={styles.sortSelect}
value={model.sort}
items={VARIABLE_SORTS.map((sort) => ({
label: SORT_LABEL[sort],
value: sort,
}))}
onChange={(value): void => set({ sort: value as VariableSort })}
testId="variable-sort-select"
/>
</div>
<div className={cx(styles.row, styles.multiSection)}>
<Typography.Text className={styles.rowLabel}>
Enable multiple values to be checked
</Typography.Text>
<Switch
value={model.multiSelect}
onChange={(checked): void => {
set({
multiSelect: checked,
showAllOption: checked ? model.showAllOption : false,
});
}}
testId="variable-multi-switch"
/>
</div>
{model.multiSelect && showAllOptionField ? (
<div className={cx(styles.row, styles.allOptionSection)}>
<Typography.Text className={styles.rowLabel}>
Include an option for ALL values
</Typography.Text>
<Switch
value={model.showAllOption}
onChange={(checked): void => set({ showAllOption: checked })}
testId="variable-all-switch"
/>
</div>
) : null}
<div className={cx(styles.row, styles.defaultValueSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
<Typography.Text className={styles.defaultValueDesc}>
{model.type === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography.Text>
</div>
<Select
className={styles.searchSelect}
showSearch
allowClear
placeholder="Select a default value"
value={defaultValue || undefined}
onChange={(value): void => setDefaultValue(value ?? '')}
options={previewValues.map((value) => ({
label: value.toString(),
value: value.toString(),
}))}
data-testid="variable-default-select"
/>
</div>
</>
) : null}
</div>
</div>
<div className={styles.footer}>
<Button
variant="solid"
color="secondary"
prefix={<X size={14} />}
onClick={onClose}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
disabled={!!nameError}
loading={isSaving}
onClick={handleSave}
testId="variable-save"
>
Save Variable
</Button>
</div>
</>
);
}
export default VariableForm;

View File

@@ -0,0 +1,99 @@
import {
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
import type { VariableType } from '../variableModel';
import styles from './VariableForm.module.scss';
interface VariableTypeSelectorProps {
value: VariableType;
onChange: (type: VariableType) => void;
}
/** The segmented Dynamic / Textbox / Custom / Query type picker. */
function VariableTypeSelector({
value,
onChange,
}: VariableTypeSelectorProps): JSX.Element {
return (
<div className={cx(styles.row, styles.typeSection)}>
<div className={styles.typeLabelContainer}>
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<div className={styles.typeBtnGroup}>
<Button
variant="ghost"
color="secondary"
prefix={<Pyramid size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'DYNAMIC',
})}
onClick={(): void => onChange('DYNAMIC')}
testId="variable-type-dynamic"
>
Dynamic
<Badge color="robin" className={styles.betaTag}>
Beta
</Badge>
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<ClipboardType size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'TEXT',
})}
onClick={(): void => onChange('TEXT')}
testId="variable-type-textbox"
>
Textbox
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<LayoutList size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'CUSTOM',
})}
onClick={(): void => onChange('CUSTOM')}
testId="variable-type-custom"
>
Custom
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<DatabaseZap size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'QUERY',
})}
onClick={(): void => onChange('QUERY')}
testId="variable-type-query"
>
Query
<Badge color="amber" className={styles.betaTag}>
Not Recommended
</Badge>
</Button>
</div>
</div>
);
}
export default VariableTypeSelector;

View File

@@ -0,0 +1,101 @@
.container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 16px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.titleRow {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
}
.title {
font-size: 14px;
font-weight: 500;
color: var(--l1-foreground);
}
.subtitle {
font-size: 12px;
color: var(--l2-foreground);
}
.empty {
padding: 32px;
text-align: center;
border: 1px dashed var(--l1-border);
border-radius: 4px;
color: var(--l2-foreground);
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l1-background);
}
.rowMain {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.varName {
font-weight: 500;
color: var(--l1-foreground);
}
.varDesc {
min-width: 0;
overflow: hidden;
font-size: 12px;
color: var(--l2-foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.typeTag {
flex-shrink: 0;
padding: 1px 8px;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--l2-foreground);
text-transform: uppercase;
background: var(--l2-background);
border-radius: 10px;
}
.rowActions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 2px;
}
.confirmText {
margin-right: 4px;
font-size: 12px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,139 @@
import {
Check,
ChevronDown,
ChevronUp,
PenLine,
Trash2,
X,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { VariableFormModel } from './variableModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariablesListProps {
variables: VariableFormModel[];
canEdit: boolean;
/** Index whose delete is awaiting inline confirmation, if any. */
confirmingIndex: number | null;
onEdit: (index: number) => void;
onRequestDelete: (index: number) => void;
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
onMove: (from: number, to: number) => void;
}
function VariablesList({
variables,
canEdit,
confirmingIndex,
onEdit,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onMove,
}: VariablesListProps): JSX.Element {
return (
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<div
className={styles.row}
key={variable.name || `variable-${index}`}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
<Typography.Text className={styles.varName}>
${variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : null}
</div>
{canEdit && confirmingIndex === index ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && confirmingIndex !== index ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === 0}
onClick={(): void => onMove(index, index - 1)}
aria-label="Move up"
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === variables.length - 1}
onClick={(): void => onMove(index, index + 1)}
aria-label="Move down"
>
<ChevronDown size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
</div>
))}
</div>
);
}
export default VariablesList;

View File

@@ -0,0 +1,147 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSaveVariables } from './useSaveVariables';
import { dtoToFormModel } from './variableAdapters';
import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableModel';
import VariableForm from './VariableForm/VariableForm';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
interface VariablesSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/** `null` index = adding a new variable; a number = editing that row. */
type EditingState = { index: number | null } | null;
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { save, isSaving } = useSaveVariables();
const initialModels = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
useEffect(() => {
setVariables(initialModels);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const [editing, setEditing] = useState<EditingState>(null);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const editingModel: VariableFormModel | null = useMemo(() => {
if (!editing) {
return null;
}
return editing.index === null
? emptyVariableFormModel()
: variables[editing.index];
}, [editing, variables]);
const existingNames = useMemo(() => {
const self = editing?.index ?? null;
return variables.filter((_, i) => i !== self).map((v) => v.name);
}, [variables, editing]);
const persist = (next: VariableFormModel[]): void => {
setVariables(next);
void save(next);
};
const handleFormSave = (model: VariableFormModel): void => {
const next = [...variables];
if (editing?.index == null) {
next.push(model);
} else {
next[editing.index] = model;
}
setEditing(null);
persist(next);
};
const handleMove = (from: number, to: number): void => {
if (to < 0 || to >= variables.length) {
return;
}
const next = [...variables];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
persist(next);
};
const handleConfirmDelete = (index: number): void => {
persist(variables.filter((_, i) => i !== index));
setConfirmDeleteIndex(null);
};
// Detail view — edit/new form replaces the list in place (no modal).
if (editingModel) {
return (
<VariableForm
initial={editingModel}
existingNames={existingNames}
isSaving={isSaving}
onClose={(): void => setEditing(null)}
onSave={handleFormSave}
/>
);
}
// Master view — the variables list.
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.titleRow}>
<Typography.Text className={styles.title}>Variables</Typography.Text>
<Typography.Text className={styles.subtitle}>
Define variables to parameterize panel queries.
</Typography.Text>
</div>
{isEditable ? (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={(): void => setEditing({ index: null })}
testId="add-variable"
>
New variable
</Button>
) : null}
</div>
{variables.length === 0 ? (
<div className={styles.empty}>
<Typography.Text>No variables defined yet.</Typography.Text>
</div>
) : (
<VariablesList
variables={variables}
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setEditing({ index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
/>
)}
</div>
);
}
export default VariablesSettings;

View File

@@ -0,0 +1,51 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { toast } from '@signozhq/ui/sonner';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableModel';
import { buildVariablesPatch } from './variablePatchOps';
interface UseSaveVariables {
save: (variables: VariableFormModel[]) => Promise<boolean>;
isSaving: boolean;
}
/**
* Persists the dashboard's variable list via a single `/spec/variables` patch,
* then refetches. Mirrors the General-settings save flow (patch → toast →
* refetch → surface errors).
*/
export function useSaveVariables(): UseSaveVariables {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
const [isSaving, setIsSaving] = useState(false);
const save = useCallback(
async (variables: VariableFormModel[]): Promise<boolean> => {
if (!dashboardId) {
return false;
}
const dtos = variables.map(formModelToDto);
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
toast.success('Variables updated');
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
return false;
} finally {
setIsSaving(false);
}
},
[dashboardId, refetch, showErrorModal],
);
return { save, isSaving };
}

View File

@@ -0,0 +1,153 @@
import {
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
DashboardtypesVariablePluginDTO,
DashboardTextVariableSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
PLUGIN_KIND,
type TelemetrySignal,
type VariableFormModel,
type VariableSort,
} from './variableModel';
/** DTO envelope → flat form model (for display / editing). */
export function dtoToFormModel(
dto: DashboardtypesVariableDTO,
): VariableFormModel {
const base = emptyVariableFormModel();
const display = dto.spec?.display;
const common: VariableFormModel = {
...base,
name: dto.spec?.name ?? display?.name ?? '',
description: display?.description ?? '',
};
// Text variable — a distinct envelope (no list plugin).
if (dto.kind === TextEnvelopeKind.TextVariable) {
const spec = dto.spec as DashboardTextVariableSpecDTO;
return {
...common,
type: 'TEXT',
textValue: spec.value ?? '',
textConstant: spec.constant ?? false,
};
}
// List variable — Query / Custom / Dynamic, distinguished by plugin.kind.
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
const listCommon: VariableFormModel = {
...common,
multiSelect: spec.allowMultiple ?? false,
showAllOption: spec.allowAllValue ?? false,
sort: (spec.sort as VariableSort) ?? 'DISABLED',
defaultValue: spec.defaultValue,
};
const plugin = spec.plugin;
if (plugin?.kind === CustomPluginKind['signoz/CustomVariable']) {
return {
...listCommon,
type: 'CUSTOM',
customValue: plugin.spec.customValue ?? '',
};
}
if (plugin?.kind === DynamicPluginKind['signoz/DynamicVariable']) {
return {
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).
return {
...listCommon,
type: 'QUERY',
queryValue:
plugin?.kind === QueryPluginKind['signoz/QueryVariable']
? (plugin.spec.queryValue ?? '')
: '',
};
}
function buildPlugin(
model: VariableFormModel,
): DashboardtypesVariablePluginDTO {
switch (model.type) {
case 'CUSTOM':
return {
kind: CustomPluginKind['signoz/CustomVariable'],
spec: { customValue: model.customValue },
};
case 'DYNAMIC':
return {
kind: DynamicPluginKind['signoz/DynamicVariable'],
spec: {
name: model.dynamicAttribute,
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
},
};
case 'QUERY':
default:
return {
kind: QueryPluginKind['signoz/QueryVariable'],
spec: { queryValue: model.queryValue },
};
}
}
/** Flat form model → DTO envelope (for persistence). */
export function formModelToDto(
model: VariableFormModel,
): DashboardtypesVariableDTO {
const display = {
name: model.name,
description: model.description,
hidden: model.hidden,
};
if (model.type === 'TEXT') {
return {
kind: TextEnvelopeKind.TextVariable,
spec: {
name: model.name,
display,
value: model.textValue,
constant: model.textConstant,
},
};
}
return {
kind: ListEnvelopeKind.ListVariable,
spec: {
name: model.name,
display,
allowMultiple: model.multiSelect,
allowAllValue: model.showAllOption,
sort: model.sort,
defaultValue: model.defaultValue,
plugin: buildPlugin(model),
},
};
}
/** Maps the V2 plugin/envelope to the four UI-facing variable types. */
export function variableTypeOf(
dto: DashboardtypesVariableDTO,
): VariableFormModel['type'] {
return dtoToFormModel(dto).type;
}
export { PLUGIN_KIND };

View File

@@ -0,0 +1,78 @@
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
* (`DashboardtypesVariableDTO`) is a nested envelope/plugin union that is awkward
* to bind a form to; `variableAdapters` converts between this model and the DTO.
*/
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
export type VariableSort = 'DISABLED' | 'ASC' | 'DESC';
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
/** Wire `kind` discriminators (string values of the generated enums). */
export const ENVELOPE_KIND = {
LIST: 'ListVariable',
TEXT: 'TextVariable',
} as const;
export const PLUGIN_KIND = {
QUERY: 'signoz/QueryVariable',
CUSTOM: 'signoz/CustomVariable',
DYNAMIC: 'signoz/DynamicVariable',
} as const;
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
'traces',
'logs',
'metrics',
];
export interface VariableFormModel {
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
name: string;
description: string;
hidden: boolean;
type: VariableType;
// List-variable common fields (Query / Custom / Dynamic).
multiSelect: boolean;
showAllOption: boolean;
sort: VariableSort;
// Type-specific.
queryValue: string; // QUERY
customValue: string; // CUSTOM
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal
/**
* Runtime-selected default, not editable in the management tab yet; carried
* through edits so saving a definition doesn't clobber it.
*/
defaultValue?: VariableDefaultValueDTO;
}
export function emptyVariableFormModel(): VariableFormModel {
return {
name: '',
description: '',
hidden: false,
type: 'QUERY',
multiSelect: false,
showAllOption: false,
sort: 'DISABLED',
queryValue: '',
customValue: '',
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: 'traces',
};
}

View File

@@ -0,0 +1,22 @@
import type {
DashboardtypesJSONPatchOperationDTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Builds the JSON-Patch to persist the dashboard's variable list. Add/edit/
* delete/reorder all replace the whole `/spec/variables` array in one atomic op
* — simpler and race-free vs per-index patches. RFC-6902 `add` on an object
* member sets-or-replaces, so it works whether or not `variables` already exists.
*/
export function buildVariablesPatch(
variables: DashboardtypesVariableDTO[],
): DashboardtypesJSONPatchOperationDTO[] {
return [
{
op: 'add' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/variables',
value: variables,
},
];
}

View File

@@ -1,11 +1,21 @@
import { useMemo } from 'react';
import { Braces, Globe, Table } from '@signozhq/icons';
import { Tabs } from '@signozhq/ui/tabs';
import {
TabItemProps,
TabsContent,
TabsList,
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import GeneralSettings from './General';
import { SettingsTabPlaceholder } from './utils';
import Overview from './Overview';
import PublicDashboardSettings from './PublicDashboard';
import VariablesSettings from './Variables';
import { useAppContext } from 'providers/App/App';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { USER_ROLES } from 'types/roles';
import styles from './DashboardSettings.module.scss';
@@ -13,42 +23,69 @@ interface DashboardSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function tabLabel(icon: JSX.Element, text: string): JSX.Element {
return (
<span className={styles.tabLabel}>
{icon}
{text}
</span>
);
enum TabKeys {
OVERVIEW = 'Overview',
VARIABLES = 'Variables',
PUBLISH = 'Publish',
}
const prefixIcons: Record<TabKeys, JSX.Element> = {
[TabKeys.OVERVIEW]: <Table size={14} />,
[TabKeys.VARIABLES]: <Braces size={14} />,
[TabKeys.PUBLISH]: <Globe size={14} />,
};
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
const items = useMemo(
const { user } = useAppContext();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
const items: TabItemProps[] = useMemo(
() => [
{
key: 'general',
label: tabLabel(<Table size={14} />, 'General'),
children: <GeneralSettings dashboard={dashboard} />,
key: TabKeys.OVERVIEW,
label: TabKeys.OVERVIEW,
children: <Overview dashboard={dashboard} />,
},
{
key: 'variables',
label: tabLabel(<Braces size={14} />, 'Variables'),
children: (
<SettingsTabPlaceholder message="V2 dashboard variables coming next." />
),
},
{
key: 'public-dashboard',
label: tabLabel(<Globe size={14} />, 'Publish'),
children: (
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
),
key: TabKeys.VARIABLES,
label: TabKeys.VARIABLES,
children: <VariablesSettings dashboard={dashboard} />,
prefixIcon: <Braces size={14} />,
},
...(enablePublicDashboard
? [
{
key: TabKeys.PUBLISH,
label: TabKeys.PUBLISH,
children: <PublicDashboardSettings dashboard={dashboard} />,
disabled: user?.role !== USER_ROLES.ADMIN,
},
]
: []),
],
[dashboard],
[enablePublicDashboard, dashboard, user?.role],
);
return <Tabs defaultValue="general" items={items} />;
return (
<TabsRoot defaultValue={TabKeys.OVERVIEW}>
<TabsList variant="primary">
{Object.values(TabKeys).map((key) => (
<TabsTrigger value={key} key={key}>
{prefixIcons[key]}
{key}
</TabsTrigger>
))}
</TabsList>
{items.map((item) => (
<TabsContent value={item.key} key={item.key} className={styles.tabsContent}>
{item.children}
</TabsContent>
))}
</TabsRoot>
);
}
export default DashboardSettings;

View File

@@ -1,23 +0,0 @@
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styles from './DashboardSettings.module.scss';
/**
* TEMPORARY: stand-in for the not-yet-built Variables / Publish settings tabs.
* Will be cleaned up later once those tabs ship their real content.
*/
export function SettingsTabPlaceholder({
message,
}: {
message: string;
}): JSX.Element {
return (
<div className={styles.placeholder}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<Typography.Text>{message}</Typography.Text>}
/>
</div>
);
}

View File

@@ -1,57 +0,0 @@
.dashboardBreadcrumbs {
width: 100%;
height: 48px;
display: flex;
gap: 6px;
align-items: center;
max-width: 80%;
padding-left: 8px;
.linkToPreviousPage {
// Collapse the design-system Button's fixed-height/padding box so it hugs
// the label like inline text (the breadcrumb is text, not a chunky button).
--button-height: auto;
--button-padding: 0;
--button-gap: 4px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
}
.currentPage {
display: flex;
align-items: center;
gap: 4px;
padding: 0px 2px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
height: 20px;
max-width: calc(100% - 120px);
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.currentPage:hover {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-300);
}
.dashboardIconImage {
height: 14px;
width: 14px;
}
}

View File

@@ -1,63 +0,0 @@
import { useCallback } from 'react';
import { LayoutGrid } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import getSessionStorageApi from 'api/browser/sessionstorage/get';
import ROUTES from 'constants/routes';
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import styles from './DashboardBreadcrumbs.module.scss';
interface DashboardBreadcrumbsProps {
title: string;
image: string;
}
function DashboardBreadcrumbs({
title,
image,
}: DashboardBreadcrumbsProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const goToListPage = useCallback(() => {
const dashboardsListQueryParamsString = getSessionStorageApi(
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
);
if (dashboardsListQueryParamsString) {
safeNavigate({
pathname: ROUTES.ALL_DASHBOARD,
search: `?${dashboardsListQueryParamsString}`,
});
} else {
safeNavigate(ROUTES.ALL_DASHBOARD);
}
}, [safeNavigate]);
return (
<div className={styles.dashboardBreadcrumbs}>
<Button
variant="ghost"
color="secondary"
prefix={<LayoutGrid size={14} />}
onClick={goToListPage}
className={styles.linkToPreviousPage}
testId="dashboard-breadcrumb-list"
>
Dashboard
</Button>
<div>/</div>
<div className={styles.currentPage}>
<img
src={image}
alt="dashboard-icon"
className={styles.dashboardIconImage}
/>
<Typography.Text>{title}</Typography.Text>
</div>
</div>
);
}
export default DashboardBreadcrumbs;

View File

@@ -1,9 +0,0 @@
.dashboardHeader {
border-bottom: 1px solid var(--l1-border);
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 0 8px;
box-sizing: border-box;
}

View File

@@ -1,22 +0,0 @@
import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
import styles from './DashboardHeader.module.scss';
interface DashboardHeaderProps {
title: string;
image: string;
}
function DashboardHeader({ title, image }: DashboardHeaderProps): JSX.Element {
return (
<div className={styles.dashboardHeader}>
<DashboardBreadcrumbs title={title} image={image} />
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
</div>
);
}
export default memo(DashboardHeader);

View File

@@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { LayoutGrid } from '@signozhq/icons';
import getSessionStorageApi from 'api/browser/sessionstorage/get';
import ROUTES from 'constants/routes';
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from '@signozhq/ui/breadcrumb';
interface DashboardPageBreadcrumbsProps {
title: string;
image: string;
}
function DashboardPageBreadcrumbs({
title,
image,
}: DashboardPageBreadcrumbsProps): JSX.Element {
const dashboardPageLink = useMemo(() => {
const dashboardsListQueryParamsString = getSessionStorageApi(
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
);
return dashboardsListQueryParamsString
? `${ROUTES.ALL_DASHBOARD}?${dashboardsListQueryParamsString}`
: ROUTES.ALL_DASHBOARD;
}, []);
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink icon={<LayoutGrid size={14} />} href={dashboardPageLink}>
Dashboard
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>/</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbLink icon={<img src={image} alt="dashboard-icon" />}>
{title}
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}
export default DashboardPageBreadcrumbs;

View File

@@ -0,0 +1,9 @@
.dashboardPageHeader {
border-bottom: 1px solid var(--l2-border);
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 14px;
height: 48px;
width: 100%;
}

View File

@@ -0,0 +1,25 @@
import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
import styles from './DashboardPageHeader.module.scss';
interface DashboardPageHeaderProps {
title: string;
image: string;
}
function DashboardPageHeader({
title,
image,
}: DashboardPageHeaderProps): JSX.Element {
return (
<div className={styles.dashboardPageHeader}>
<DashboardPageBreadcrumbs title={title} image={image} />
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
</div>
);
}
export default memo(DashboardPageHeader);

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
@@ -6,10 +6,12 @@ import PanelTypeSelectionModal from 'container/DashboardContainer/PanelTypeSelec
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import DashboardDescription from './DashboardDescription';
import DashboardPageToolbar from './DashboardPageToolbar';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
import { Base64Icons } from './DashboardSettings/Overview/utils';
interface DashboardContainerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
@@ -20,32 +22,49 @@ function DashboardContainer({
dashboard,
refetch,
}: DashboardContainerProps): JSX.Element {
useEffect(() => {
document.title = dashboard.name;
}, [dashboard.name]);
const fullScreenHandle = useFullScreenHandle();
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const isEditable = !dashboard.locked && editDashboard;
const [editDashboardPermission] = useComponentPermission(
['edit_dashboard'],
user.role,
);
// Publish edit context to the store so hooks/components read it from there
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
const setEditContext = useDashboardStore((s) => s.setEditContext);
useEffect(() => {
setEditContext({ dashboardId: dashboard.id ?? '', isEditable, refetch });
}, [dashboard.id, isEditable, refetch, setEditContext]);
setEditContext({
dashboardId: dashboard.id,
isEditable: !dashboard.locked && editDashboardPermission,
refetch,
});
}, [
dashboard.id,
dashboard.locked,
editDashboardPermission,
refetch,
setEditContext,
]);
const { spec } = dashboard;
const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]);
const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]);
const spec = dashboard.spec;
const image = dashboard.image || Base64Icons[0];
const name = spec.display.name;
return (
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>
<DashboardDescription
<DashboardPageHeader title={name} image={image} />
<DashboardPageToolbar
dashboard={dashboard}
handle={fullScreenHandle}
refetch={refetch}
/>
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
</div>
{/* Shared panel-type picker (V1 component): opened from any "New Panel"
trigger; navigates to the widget editor route on selection. */}

View File

@@ -13,7 +13,7 @@ import type { GridItem } from './utils';
* intentionally side-effect-free (no React, no network) so they can be unit
* tested and reused by the layout hooks. JSON pointers target the postable
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
* patches in DashboardSettings/General and DashboardDescription).
* patches in DashboardSettings/Overview and DashboardDescription).
*/
const { add, replace, remove } = DashboardtypesPatchOpDTO;

View File

@@ -1,4 +1,3 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
@@ -16,13 +15,6 @@ function DashboardPageV2(): JSX.Element {
});
const dashboard = data?.data;
const name = dashboard?.spec?.display?.name;
useEffect(() => {
if (name) {
document.title = name;
}
}, [name]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;

View File

@@ -1,8 +1,12 @@
.settings-page {
max-height: 100vh;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
.settings-page-header {
flex-shrink: 0;
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);
backdrop-filter: blur(20px);
@@ -24,13 +28,14 @@
}
.settings-page-content-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
align-items: flex-start;
align-items: stretch;
.settings-page-sidenav {
width: 240px;
height: calc(100vh - 48px);
border-right: 1px solid var(--l1-border);
background: var(--l1-background);
padding-top: var(--padding-1);
@@ -74,7 +79,6 @@
.settings-page-content {
flex: 1;
height: calc(100vh - 48px);
background: var(--l1-background);
padding: 10px 8px;
overflow-y: auto;

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { Skeleton } from 'antd';
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
import useUrlQuery from 'hooks/useUrlQuery';
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
import { SpanV3 } from 'types/api/trace/getTraceV3';
@@ -53,6 +53,9 @@ function TraceFlamegraph({
);
const previewFields = useTraceStore((s) => s.previewFields);
// Gate the fetch until prefs load, else selectFields (in the query key)
// repopulates and triggers a second fetch.
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
// Color-by fields baseline + user-picked preview fields. De-duped by `name`,
// color-by entries first so their canonical metadata wins on collision.
@@ -70,17 +73,14 @@ function TraceFlamegraph({
data,
isFetching,
error: fetchError,
} = useGetTraceFlamegraph({
} = useGetTraceFlamegraphV3({
traceId,
selectedSpanId: selectedSpanIdForFetch,
limit: FLAMEGRAPH_SPAN_LIMIT,
selectFields: flamegraphSelectFields,
enabled: !!traceId && userPrefsReady,
});
const spans = useMemo(
() => data?.payload?.spans || [],
[data?.payload?.spans],
);
const spans = useMemo(() => data?.spans || [], [data?.spans]);
const {
layout,
@@ -99,8 +99,8 @@ function TraceFlamegraph({
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
onSpanClick={handleSpanClick}
traceMetadata={{
startTime: data?.payload?.startTimestampMillis || 0,
endTime: data?.payload?.endTimestampMillis || 0,
startTime: data?.startTimestampMillis || 0,
endTime: data?.endTimestampMillis || 0,
}}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
@@ -124,7 +124,7 @@ function TraceFlamegraph({
if (fetchError || workerError) {
return <Error error={(fetchError || workerError) as any} />;
}
if (data?.payload?.spans && data.payload.spans.length === 0) {
if (data?.spans && data.spans.length === 0) {
return <div>No data found for trace {traceId}</div>;
}
return (
@@ -134,17 +134,17 @@ function TraceFlamegraph({
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
onSpanClick={handleSpanClick}
traceMetadata={{
startTime: data?.payload?.startTimestampMillis || 0,
endTime: data?.payload?.endTimestampMillis || 0,
startTime: data?.startTimestampMillis || 0,
endTime: data?.endTimestampMillis || 0,
}}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
);
}, [
data?.payload?.endTimestampMillis,
data?.payload?.startTimestampMillis,
data?.payload?.spans,
data?.endTimestampMillis,
data?.startTimestampMillis,
data?.spans,
fetchError,
filteredSpanIds,
firstSpanAtFetchLevel,

View File

@@ -1,12 +1,12 @@
import { render } from '@testing-library/react';
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
import { AllTheProviders } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { FLAMEGRAPH_SPAN_LIMIT } from '../constants';
import TraceFlamegraph from '../TraceFlamegraph';
jest.mock('hooks/trace/useGetTraceFlamegraph');
jest.mock('hooks/trace/useGetTraceFlamegraphV3');
// Short-circuit the worker so the test doesn't depend on layout computation.
jest.mock('../hooks/useVisualLayoutWorker', () => ({
@@ -17,9 +17,8 @@ jest.mock('../hooks/useVisualLayoutWorker', () => ({
}),
}));
const mockUseGetTraceFlamegraph = useGetTraceFlamegraph as jest.MockedFunction<
typeof useGetTraceFlamegraph
>;
const mockUseGetTraceFlamegraph =
useGetTraceFlamegraphV3 as jest.MockedFunction<typeof useGetTraceFlamegraphV3>;
function renderFlamegraph(props: {
selectedSpan: SpanV3 | undefined;
@@ -45,7 +44,7 @@ describe('TraceFlamegraph - selectedSpanId pass-through', () => {
beforeEach(() => {
mockUseGetTraceFlamegraph.mockReset();
mockUseGetTraceFlamegraph.mockReturnValue({
data: { payload: { spans: [] } },
data: { spans: [] },
isFetching: false,
error: null,
} as never);

View File

@@ -1,4 +1,4 @@
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import {
computeVisualLayout,
@@ -14,12 +14,12 @@ function makeSpan(
): FlamegraphSpan {
return {
parentSpanId: '',
traceId: 'trace-1',
hasError: false,
serviceName: 'svc',
name: 'op',
level: 0,
event: [],
resource: {},
attributes: {},
...overrides,
};
}

View File

@@ -1,4 +1,4 @@
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
/** Minimal FlamegraphSpan for unit tests */
export const MOCK_SPAN: FlamegraphSpan = {
@@ -6,12 +6,12 @@ export const MOCK_SPAN: FlamegraphSpan = {
durationNano: 50_000_000, // 50ms
spanId: 'span-1',
parentSpanId: '',
traceId: 'trace-1',
hasError: false,
serviceName: 'test-service',
name: 'test-span',
level: 0,
event: [],
resource: {},
attributes: {},
};
/** Nested spans structure for findSpanById tests */

View File

@@ -65,37 +65,25 @@ describe('Presentation / Styling Utils', () => {
describe('getFlamegraphSpanGroupValue', () => {
it('returns resource[field.name] when present', () => {
const value = getFlamegraphSpanGroupValue(
{
serviceName: 'legacy',
resource: { 'service.name': 'svc-from-resource' },
},
{ resource: { 'service.name': 'svc-from-resource' } },
SERVICE_FIELD,
);
expect(value).toBe('svc-from-resource');
});
it('falls back to top-level serviceName for service.name when resource is empty', () => {
const value = getFlamegraphSpanGroupValue(
{ serviceName: 'svc-legacy', resource: {} },
SERVICE_FIELD,
);
expect(value).toBe('svc-legacy');
it('returns "unknown" for service.name when resource is empty', () => {
const value = getFlamegraphSpanGroupValue({ resource: {} }, SERVICE_FIELD);
expect(value).toBe('unknown');
});
it('returns "unknown" for non-service fields when resource is missing', () => {
const value = getFlamegraphSpanGroupValue(
{ serviceName: 'svc', resource: {} },
HOST_FIELD,
);
const value = getFlamegraphSpanGroupValue({ resource: {} }, HOST_FIELD);
expect(value).toBe('unknown');
});
it('reads host.name from resource when present', () => {
const value = getFlamegraphSpanGroupValue(
{
serviceName: 'svc',
resource: { 'host.name': 'host-1' },
},
{ resource: { 'host.name': 'host-1' } },
HOST_FIELD,
);
expect(value).toBe('host-1');

View File

@@ -1,11 +1,10 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
export interface ConnectorLine {
parentRow: number;
childRow: number;
timestampMs: number;
serviceName: string;
// Snapshot of the child span's resource so draw-time can resolve the
// `colorByField` group value without crossing the worker boundary.
resource?: Record<string, string>;
@@ -159,24 +158,8 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
}
}
// Extract parentSpanId — the field may be missing at runtime when the API
// returns `references` instead. Fall back to the first CHILD_OF reference.
function getParentId(span: FlamegraphSpan): string {
if (span.parentSpanId) {
return span.parentSpanId;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const refs = (span as any).references as
| Array<{ spanId?: string; refType?: string }>
| undefined;
if (refs) {
for (const ref of refs) {
if (ref.refType === 'CHILD_OF' && ref.spanId) {
return ref.spanId;
}
}
}
return '';
return span.parentSpanId || '';
}
// Build children map and identify roots
@@ -480,7 +463,6 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
parentRow,
childRow,
timestampMs: child.timestamp,
serviceName: child.serviceName,
resource: child.resource,
});
}

View File

@@ -1,7 +1,7 @@
import React, { RefObject, useCallback, useMemo, useRef } from 'react';
import { generateColorPair } from 'pages/TraceDetailsV3/utils/generateColorPair';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { ConnectorLine } from '../computeVisualLayout';
@@ -200,7 +200,7 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
}
const groupValue = getFlamegraphSpanGroupValue(
{ serviceName: conn.serviceName, resource: conn.resource },
{ resource: conn.resource },
colorByField,
);
const pair = generateColorPair(groupValue);

View File

@@ -11,10 +11,9 @@ import {
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { RESERVED_PREVIEW_KEYS } from 'pages/TraceDetailsV3/SpanHoverCard/SpanHoverCard';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { EventRect, SpanRect } from '../types';
import { ITraceMetadata } from '../types';
import { EventRect, ITraceMetadata, SpanRect } from '../types';
import {
getFlamegraphServiceName,
getFlamegraphSpanGroupValue,
@@ -200,7 +199,7 @@ export function useFlamegraphHover(
if (eventRect) {
const { event, span } = eventRect;
const eventTimeMs = event.timeUnixNano / 1e6;
const eventTimeMs = (event.timeUnixNano ?? 0) / 1e6;
setHoveredEventKey(`${span.spanId}-${event.name}-${event.timeUnixNano}`);
setHoveredSpanId(span.spanId);
setTooltipContent({
@@ -220,10 +219,10 @@ export function useFlamegraphHover(
return isDarkMode ? pair.color : pair.colorDark;
})(),
event: {
name: event.name,
name: event.name ?? '',
timeOffsetMs: eventTimeMs - span.timestamp,
isError: event.isError,
attributeMap: event.attributeMap || {},
isError: event.isError ?? false,
attributeMap: (event.attributeMap as Record<string, string>) ?? {},
},
});
updateCursor(canvas, eventRect.span);

View File

@@ -5,7 +5,7 @@ import {
SetStateAction,
useEffect,
} from 'react';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { MIN_VISIBLE_SPAN_MS } from '../constants';
import { ITraceMetadata } from '../types';

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { computeVisualLayout, VisualLayout } from '../computeVisualLayout';
import { LayoutWorkerResponse } from '../visualLayoutWorkerTypes';

View File

@@ -1,5 +1,8 @@
import {
SpantypesEventDTO as FlamegraphEvent,
SpantypesFlamegraphSpanDTO as FlamegraphSpan,
} from 'api/generated/services/sigNoz.schemas';
import { Dispatch, SetStateAction } from 'react';
import { Event, FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { VisualLayout } from './computeVisualLayout';
@@ -28,7 +31,7 @@ export interface SpanRect {
}
export interface EventRect {
event: Event;
event: FlamegraphEvent;
span: FlamegraphSpan;
cx: number;
cy: number;

View File

@@ -7,7 +7,7 @@ import {
generateColorPair,
RESERVED_ERROR,
} from 'pages/TraceDetailsV3/utils/generateColorPair';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import {
@@ -74,34 +74,25 @@ export function getFlamegraphRowMetrics(
/**
* Resolve the displayed service.name for a flamegraph span. Used by tooltips
* (service identity, independent of the active colour-by field). Prefers
* `resource['service.name']` with legacy top-level `serviceName` fallback.
* (service identity, independent of the active colour-by field). Reads
* `resource['service.name']`.
*/
export function getFlamegraphServiceName(
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
): string {
return getSpanAttribute(span, 'service.name') || span.serviceName || '';
return getSpanAttribute(span, 'service.name') || '';
}
/**
* Resolve the value used to bucket a flamegraph span by colour for the given
* field. Prefers `resource[field.name]` (new contract from `selectFields`).
* For `service.name`, falls back to the legacy top-level `serviceName` when
* resource is empty (backward-compat with backends that haven't shipped
* `selectFields` yet). For other fields, falls back to `'unknown'`.
* field. Prefers `resource[field.name]` (contract from `selectFields`), falling
* back to `'unknown'`.
*/
export function getFlamegraphSpanGroupValue(
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
field: TelemetryFieldKey,
): string {
const fromAttribute = getSpanAttribute(span, field.name);
if (fromAttribute) {
return fromAttribute;
}
if (field.name === 'service.name') {
return span.serviceName || 'unknown';
}
return 'unknown';
return getSpanAttribute(span, field.name) || 'unknown';
}
interface GetSpanColorArgs {
@@ -296,7 +287,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
return;
}
const eventTimeMs = event.timeUnixNano / 1e6;
const eventTimeMs = (event.timeUnixNano ?? 0) / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
const clampedOffset = clamp(eventOffsetPercent, 1, 99);
@@ -306,7 +297,11 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
// Event dots derive from the effective bar color so they track the
// light/dark variant the bar is rendered with.
const parentBarColor = isDarkMode ? color : colorDark;
const dotColor = getEventDotColor(parentBarColor, event.isError, isDarkMode);
const dotColor = getEventDotColor(
parentBarColor,
event.isError ?? false,
isDarkMode,
);
const eventKey = `${span.spanId}-${event.name}-${event.timeUnixNano}`;
const isEventHovered = hoveredEventKey === eventKey;
const dotSize = isEventHovered

View File

@@ -1,4 +1,4 @@
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { VisualLayout } from './computeVisualLayout';

View File

@@ -19,17 +19,14 @@ import {
} from 'components/CustomTimePicker/timezoneUtils';
import { LOCALSTORAGE } from 'constants/localStorage';
import useTimezoneFormatter, {
TimestampInput,
FormatTimezoneAdjustedTimestamp,
} from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
export interface TimezoneContextType {
timezone: Timezone;
browserTimezone: Timezone;
updateTimezone: (timezone: Timezone) => void;
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string;
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp;
isAdaptationEnabled: boolean;
setIsAdaptationEnabled: Dispatch<SetStateAction<boolean>>;
}

View File

@@ -2,6 +2,7 @@ package sqlalertmanagerstore
import (
"context"
"encoding/json"
"log/slog"
"time"
@@ -39,16 +40,20 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
return nil, err
}
gettablePlannedMaintenance := make([]*alertmanagertypes.PlannedMaintenance, 0)
plannedMaintenances := make([]*alertmanagertypes.PlannedMaintenance, 0, len(gettableMaintenancesRules))
for _, gettableMaintenancesRule := range gettableMaintenancesRules {
m := gettableMaintenancesRule.ToPlannedMaintenance()
gettablePlannedMaintenance = append(gettablePlannedMaintenance, m)
if m.HasScheduleRecurrenceBoundsMismatch() {
r.logger.WarnContext(ctx, "planned_downtime_recurrence_schedule_mismatch", slog.String("maintenance_id", m.ID.StringValue()))
pm, err := gettableMaintenancesRule.ToPlannedMaintenance()
if err != nil {
// Don't return an error because we want to process all the valid records.
// Log and skip instead.
r.logger.WarnContext(ctx, "skipping planned maintenance", slog.String("maintenance_id", gettableMaintenancesRule.ID.StringValue()), errors.Attr(err))
continue
}
plannedMaintenances = append(plannedMaintenances, pm)
}
return gettablePlannedMaintenance, nil
return plannedMaintenances, nil
}
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
@@ -64,7 +69,7 @@ func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.U
return nil, r.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "planned maintenance with ID: %s does not exist", id.StringValue())
}
return storableMaintenanceRule.ToPlannedMaintenance(), nil
return storableMaintenanceRule.ToPlannedMaintenance()
}
func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance) (*alertmanagertypes.PlannedMaintenance, error) {
@@ -73,6 +78,11 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
return nil, err
}
schedule, err := json.Marshal(maintenance.Schedule)
if err != nil {
return nil, err
}
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
@@ -87,7 +97,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
},
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
Schedule: string(schedule),
OrgID: claims.OrgID,
Scope: maintenance.Scope,
}
@@ -135,18 +145,21 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
return nil, err
}
return &alertmanagertypes.PlannedMaintenance{
pm := &alertmanagertypes.PlannedMaintenance{
ID: storablePlannedMaintenance.ID,
Name: storablePlannedMaintenance.Name,
Description: storablePlannedMaintenance.Description,
Schedule: storablePlannedMaintenance.Schedule,
RuleIDs: maintenance.AlertIds,
Scope: maintenance.Scope,
CreatedAt: storablePlannedMaintenance.CreatedAt,
CreatedBy: storablePlannedMaintenance.CreatedBy,
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
UpdatedBy: storablePlannedMaintenance.UpdatedBy,
}, nil
}
if err = json.Unmarshal([]byte(storablePlannedMaintenance.Schedule), &pm.Schedule); err != nil {
return nil, err
}
return pm, nil
}
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
@@ -174,6 +187,11 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
return err
}
schedule, err := json.Marshal(maintenance.Schedule)
if err != nil {
return err
}
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{
ID: id,
@@ -188,7 +206,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
},
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
Schedule: string(schedule),
OrgID: claims.OrgID,
Scope: maintenance.Scope,
}

View File

@@ -0,0 +1,94 @@
package sqlalertmanagerstore
import (
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func newTestStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
store, err := sqlitesqlstore.New(t.Context(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{
MaxOpenConns: 1,
MaxConnLifetime: 0,
},
Sqlite: sqlstore.SqliteConfig{
Path: filepath.Join(t.TempDir(), "test.db"),
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
_, err = store.BunDB().NewCreateTable().
Model((*alertmanagertypes.StorablePlannedMaintenance)(nil)).
IfNotExists().
Exec(t.Context())
require.NoError(t, err)
_, err = store.BunDB().NewCreateTable().
Model((*alertmanagertypes.StorablePlannedMaintenanceRule)(nil)).
IfNotExists().
Exec(t.Context())
require.NoError(t, err)
return store
}
// TestListPlannedMaintenanceSkipsInvalid asserts that a single corrupt record
// (here, an unloadable timezone) is skipped rather than failing the whole list.
func TestListPlannedMaintenanceSkipsInvalid(t *testing.T) {
store := newTestStore(t)
orgID := valuer.GenerateUUID().StringValue()
now := time.Now().UTC()
valid := &alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
Name: "valid",
Schedule: `{"timezone":"UTC","startTime":"2024-01-01T12:00:00Z","recurrence":{"duration":"2h","repeatType":"daily"}}`,
OrgID: orgID,
}
result, err := store.BunDB().NewInsert().Model(valid).Exec(t.Context())
require.NoError(t, err)
rowsAffected, err := result.RowsAffected()
require.NoError(t, err)
require.Equal(t, int64(1), rowsAffected)
// A schedule with "zero" startTime
invalid := &alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
},
Name: "invalid",
Schedule: `{"timezone":"UTC","recurrence":{"duration":"2h","repeatType":"daily"}}`,
OrgID: orgID,
}
result, err = store.BunDB().NewInsert().Model(invalid).Exec(t.Context())
require.NoError(t, err)
rowsAffected, err = result.RowsAffected()
require.NoError(t, err)
require.Equal(t, int64(1), rowsAffected)
maintenanceStore := NewMaintenanceStore(store, factorytest.NewSettings())
list, err := maintenanceStore.ListPlannedMaintenance(t.Context(), orgID)
require.NoError(t, err)
require.Len(t, list, 1)
assert.Equal(t, valid.ID, list[0].ID)
}

View File

@@ -3,6 +3,8 @@ package alertmanager
import (
"context"
"net/url"
"os"
"strings"
"testing"
"time"
@@ -14,7 +16,23 @@ import (
"github.com/stretchr/testify/require"
)
const prefix = "SIGNOZ_"
// clearSignozEnv unsets all existing SIGNOZ_* env vars for the duration of the test.
func clearSignozEnv(t *testing.T) {
t.Helper()
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, prefix) {
key := strings.SplitN(kv, "=", 2)[0]
orig, _ := os.LookupEnv(key)
_ = os.Unsetenv(key)
t.Cleanup(func() { _ = os.Setenv(key, orig) })
}
}
}
func TestNewWithEnvProvider(t *testing.T) {
clearSignozEnv(t)
t.Setenv("SIGNOZ_ALERTMANAGER_PROVIDER", "signoz")
t.Setenv("SIGNOZ_ALERTMANAGER_LEGACY_API__URL", "http://localhost:9093/api")
t.Setenv("SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_REPEAT__INTERVAL", "5m")

View File

@@ -18,8 +18,8 @@ func clearSignozEnv(t *testing.T) {
if strings.HasPrefix(kv, prefix) {
key := strings.SplitN(kv, "=", 2)[0]
orig, _ := os.LookupEnv(key)
os.Unsetenv(key)
t.Cleanup(func() { os.Setenv(key, orig) })
_ = os.Unsetenv(key)
t.Cleanup(func() { _ = os.Setenv(key, orig) })
}
}
}

View File

@@ -10,7 +10,9 @@ import (
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -48,6 +50,8 @@ func NewModule(
}
func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListHosts")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -161,6 +165,8 @@ func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframon
}
func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (*inframonitoringtypes.Pods, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListPods")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -244,6 +250,8 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
}
func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListNodes")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -332,6 +340,8 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
}
func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNamespaces) (*inframonitoringtypes.Namespaces, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListNamespaces")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -414,6 +424,8 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
}
func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (*inframonitoringtypes.Clusters, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListClusters")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -503,6 +515,8 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
}
func (m *module) ListVolumes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableVolumes) (*inframonitoringtypes.Volumes, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListVolumes")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -586,6 +600,8 @@ func (m *module) ListVolumes(ctx context.Context, orgID valuer.UUID, req *infram
}
func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDeployments) (*inframonitoringtypes.Deployments, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListDeployments")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -674,6 +690,8 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
}
func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableStatefulSets) (*inframonitoringtypes.StatefulSets, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListStatefulSets")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -764,6 +782,8 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
}
func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableJobs) (*inframonitoringtypes.Jobs, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListJobs")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -854,6 +874,8 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
}
func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDaemonSets) (*inframonitoringtypes.DaemonSets, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListDaemonSets")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -942,3 +964,12 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
func (m *module) withInfraMonitoringContext(ctx context.Context, functionName string) context.Context {
comments := map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
instrumentationtypes.CodeNamespace: "infra-monitoring",
instrumentationtypes.CodeFunctionName: functionName,
}
return ctxtypes.NewContextWithCommentVals(ctx, comments)
}

View File

@@ -182,7 +182,7 @@ func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary
return nil, spantypes.ErrTraceNotFound
}
flamegraphTrace := spantypes.NewFlamegraphTraceFromStorable(fullSpans, selectFields)
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start.UnixMilli(), summary.End.UnixMilli(), false), nil
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start, summary.End, false), nil
}
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
@@ -209,10 +209,6 @@ func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpa
return nil, err
}
return spantypes.NewGettableFlamegraphTrace(
flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields),
summary.Start.UnixMilli(),
summary.End.UnixMilli(),
true,
), nil
enrichedSpans := flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields)
return spantypes.NewGettableFlamegraphTrace(enrichedSpans, summary.Start, summary.End, true), nil
}

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