Compare commits

..

13 Commits

Author SHA1 Message Date
nityanandagohain
eb9d1d25be chore: add search and override filters in pricing model list api 2026-06-16 17:16:58 +05:30
Srikanth Chekuri
e1e9d516ac chore: add firing alert count and system/k8s metric existance status (#11730)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-06-16 10:28:22 +00:00
Pandey
58b55c922d fix(openapi): omit content type for responses without a body (#11720)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
ServeOpenAPI's nil-response branch still passed WithContentType, so any route
with Response == nil but a ResponseContentType set (notably 204 No Content)
emitted a content block in the generated spec. Clients then try to decode an
empty body and fail — e.g. "unexpected end of JSON input" on
DELETE /api/v1/service_accounts/{id}.

Omit the content type when Response is nil. Regenerate docs/api/openapi.yml (18
bodyless responses drop their content block) and the frontend orval client.

Signed-off-by: grandwizard28 <vibhupandey28@gmail.com>
2026-06-15 13:07:16 +00:00
Tushar Vats
629ea3b8be feat: extend error responses with new error struct (#11542)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: extend error responses with new error struct

* fix: enriched error for dashboard api

* fix: merge issues

* fix: reverted dashboards changes and add for cloud integrations

* fix: delete file

* fix: add back file

* fix: added a helper

* fix: removed invlaid referencess

* fix: generate openapi

* fix: keeping additional along with suggestion

* Revert "fix: keeping additional along with suggestion"

This reverts commit be30e2ffd2.

* fix: added suggestions per additonal error

* fix: generate openapi

* fix: remove valid references

* fix: removeg valid references for select and group by and only did you mean is kept

* fix: unit test

* fix: use binding for deconding for both ee and community

* fix: trim down suggestions methods

* fix: added renamed methods and moved stuff around

* fix: typo

* fix: removed json decoder

* fix: added empty check

* fix: retain addtional

* fix: reverted re-structing of file
2026-06-15 11:58:12 +00:00
Pandey
287b60cbe6 feat(statsreporter): expose collected stats via GET /api/v1/stats (#11698)
* feat(statsreporter): expose collected stats via GET /api/v1/stats

Extract per-org stats collection out of the analytics reporter into an
always-on Aggregator (collector fan-out + telemetry-store counts) shared
by the reporter and a new HTTP handler. The GET /api/v1/stats endpoint
returns the caller's org stats regardless of whether scheduled reporting
is enabled.

* refactor(statsreporter): collect telemetry stats via the querier

Move the trace/log/metric row-count and last-observed queries out of the
stats aggregator and into the querier, which now implements
statsreporter.StatsCollector. The aggregator becomes a pure collector
fan-out and no longer depends on telemetrystore; the querier is wired in
as one of the stats collectors.

* chore: regenerate openapi spec and frontend client

Backend docs/api/openapi.yml gains the GET /api/v1/stats (GetStats)
operation; the Orval client gains a useGetStats hook and GetStats200
type.

* chore: remove comment from querier Collect

* fix(statsreporter): use MustNewUUID for org from claims

Claims come from validated auth context, so the org UUID is guaranteed
valid; drop the dead NewUUID error branch.

* fix(flagger): use MustNewUUID for org from claims

Claims come from validated auth context, so the org UUID is guaranteed
valid; drop the dead NewUUID error branch.

* docs(contributing): note MustNewUUID for IDs from claims

* perf(querier): combine count and last-observed into one query per signal

Each signal's COUNT(*) and max(timestamp) scan the same table, so fetch
both in a single query — 3 queries instead of 6. Same emitted keys and
empty-table guard.
2026-06-15 11:27:17 +00:00
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
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
183 changed files with 8136 additions and 4296 deletions

View File

@@ -19,5 +19,8 @@
"editor.defaultFormatter": "vscode.html-language-features"
},
"python-envs.defaultEnvManager": "ms-python.python:system",
"python-envs.pythonProjects": []
"python-envs.pythonProjects": [],
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
}

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:
@@ -3573,10 +3566,6 @@ components:
items:
$ref: '#/components/schemas/ErrorsResponseerroradditional'
type: array
invalidReferences:
items:
type: string
type: array
message:
type: string
retry:
@@ -3597,6 +3586,10 @@ components:
properties:
message:
type: string
suggestions:
items:
type: string
type: array
type: object
ErrorsResponseretryjson:
properties:
@@ -9011,10 +9004,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -9167,10 +9156,6 @@ paths:
$ref: '#/components/schemas/DashboardtypesUpdatablePublicDashboard'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -9765,10 +9750,6 @@ paths:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
responses:
"200":
content:
application/json:
schema:
type: string
description: OK
"400":
content:
@@ -10210,6 +10191,15 @@ paths:
name: limit
schema:
type: integer
- in: query
name: q
schema:
type: string
- in: query
name: isOverride
schema:
nullable: true
type: boolean
responses:
"200":
content:
@@ -10953,10 +10943,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -11070,10 +11056,6 @@ paths:
$ref: '#/components/schemas/AuthtypesPatchableRole'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -11220,10 +11202,6 @@ paths:
$ref: '#/components/schemas/CoretypesPatchableObjects'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -11673,10 +11651,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -11784,10 +11758,6 @@ paths:
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -11969,10 +11939,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -12030,10 +11996,6 @@ paths:
$ref: '#/components/schemas/ServiceaccounttypesUpdatableFactorAPIKey'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -12216,10 +12178,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -12295,10 +12253,6 @@ paths:
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"404":
content:
@@ -12790,6 +12744,53 @@ paths:
summary: Update a span mapper
tags:
- spanmapper
/api/v1/stats:
get:
deprecated: false
description: This endpoint returns the collected stats for the organization
operationId: GetStats
responses:
"200":
content:
application/json:
schema:
properties:
data:
additionalProperties: {}
type: object
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get stats
tags:
- stats
/api/v1/testChannel:
post:
deprecated: true
@@ -13476,10 +13477,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -13739,10 +13736,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -13795,10 +13788,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -15529,10 +15518,6 @@ paths:
$ref: '#/components/schemas/MetricsexplorertypesUpdateMetricMetadataRequest'
responses:
"200":
content:
application/json:
schema:
type: string
description: OK
"400":
content:
@@ -20831,10 +20816,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -20882,10 +20863,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:

View File

@@ -109,6 +109,20 @@ func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
}
```
When you need an ID from `claims` as a `valuer.UUID` (for example to pass it to a module), derive it with the `Must*` constructor instead of `NewUUID` plus an error check. Claims are validated by the auth middleware, so the conversion cannot fail and the error branch would be dead code:
```go
// Good — claims are pre-validated, the conversion cannot fail.
orgID := valuer.MustNewUUID(claims.OrgID)
// Avoid — the error path is unreachable.
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
```
### 3. Register the handler in `signozapiserver`
In `pkg/apiserver/signozapiserver`, add a route in the appropriate `add*Routes` function (`addUserRoutes`, `addSessionRoutes`, `addOrgRoutes`, etc.). The pattern is:
@@ -387,3 +401,4 @@ Note the discriminator property lives in the variants, not on the parent — the
- **Add `nullable:"true"`** on fields that can be `null`. Pay special attention to slices and maps -- in Go these default to `nil` which serializes to `null`. If the field should always be an array, initialize it and do not mark it nullable.
- **Implement `Enum()`** on every type that has a fixed set of acceptable values so the JSON schema generates proper `enum` constraints.
- **Add request examples** via `RequestExamples` in `OpenAPIDef` for any non-trivial endpoint. See `pkg/apiserver/signozapiserver/querier.go` for reference.
- **Derive IDs from `claims` with `valuer.MustNewUUID`** (e.g. `claims.OrgID`, `claims.UserID`). Claims are pre-validated by the auth middleware, so use the `Must*` constructor — don't write `NewUUID` followed by an `if err != nil { render.Error(...); return }` block.

View File

@@ -3,13 +3,13 @@ package querier
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
anomalyV2 "github.com/SigNoz/signoz/ee/anomaly"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -48,8 +48,8 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
}
var queryRangeRequest qbtypes.QueryRangeRequest
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to decode request body: %v", err))
if err := binding.JSON.BindBody(req.Body, &queryRangeRequest); err != nil {
render.Error(rw, err)
return
}

View File

@@ -63,7 +63,7 @@ export const deletePublicDashboard = (
{ id }: DeletePublicDashboardPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/dashboards/${id}/public`,
method: 'DELETE',
signal,
@@ -346,7 +346,7 @@ export const updatePublicDashboard = (
dashboardtypesUpdatablePublicDashboardDTO?: BodyType<DashboardtypesUpdatablePublicDashboardDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/dashboards/${id}/public`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -836,7 +836,7 @@ export const deleteDashboardV2 = (
{ id }: DeleteDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v2/dashboards/${id}`,
method: 'DELETE',
signal,
@@ -1214,7 +1214,7 @@ export const unlockDashboardV2 = (
{ id }: UnlockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'DELETE',
signal,
@@ -1293,7 +1293,7 @@ export const lockDashboardV2 = (
{ id }: LockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'PUT',
signal,
@@ -1471,7 +1471,7 @@ export const unpinDashboardV2 = (
{ id }: UnpinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v2/users/me/dashboards/${id}/pins`,
method: 'DELETE',
signal,
@@ -1550,7 +1550,7 @@ export const pinDashboardV2 = (
{ id }: PinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v2/users/me/dashboards/${id}/pins`,
method: 'PUT',
signal,

View File

@@ -37,7 +37,7 @@ export const handleExportRawDataPOST = (
params?: HandleExportRawDataPOSTParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/export_raw_data`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -680,7 +680,7 @@ export const updateMetricMetadata = (
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v2/metrics/${metricName}/metadata`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -203,7 +203,7 @@ export const deleteRole = (
{ id }: DeleteRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}`,
method: 'DELETE',
signal,
@@ -372,7 +372,7 @@ export const patchRole = (
authtypesPatchableRoleDTO?: BodyType<AuthtypesPatchableRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -572,7 +572,7 @@ export const patchObjects = (
coretypesPatchableObjectsDTO?: BodyType<CoretypesPatchableObjectsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },

View File

@@ -222,7 +222,7 @@ export const deleteServiceAccount = (
{ id }: DeleteServiceAccountPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}`,
method: 'DELETE',
signal,
@@ -405,7 +405,7 @@ export const updateServiceAccount = (
serviceaccounttypesPostableServiceAccountDTO?: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -707,7 +707,7 @@ export const revokeServiceAccountKey = (
{ id, fid }: RevokeServiceAccountKeyPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}/keys/${fid}`,
method: 'DELETE',
signal,
@@ -788,7 +788,7 @@ export const updateServiceAccountKey = (
serviceaccounttypesUpdatableFactorAPIKeyDTO?: BodyType<ServiceaccounttypesUpdatableFactorAPIKeyDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}/keys/${fid}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -1090,7 +1090,7 @@ export const deleteServiceAccountRole = (
{ id, rid }: DeleteServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
method: 'DELETE',
signal,
@@ -1254,7 +1254,7 @@ export const updateMyServiceAccount = (
serviceaccounttypesPostableServiceAccountDTO?: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/me`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },

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
*/
@@ -2153,6 +2143,10 @@ export interface ErrorsResponseerroradditionalDTO {
* @type string
*/
message?: string;
/**
* @type array
*/
suggestions?: string[];
}
export interface ErrorsResponseretryjsonDTO {
@@ -2168,10 +2162,6 @@ export interface ErrorsJSONDTO {
* @type array
*/
errors?: ErrorsResponseerroradditionalDTO[];
/**
* @type array
*/
invalidReferences?: string[];
/**
* @type string
*/
@@ -9398,6 +9388,16 @@ export type ListLLMPricingRulesParams = {
* @description undefined
*/
limit?: number;
/**
* @type string
* @description undefined
*/
q?: string;
/**
* @type boolean,null
* @description undefined
*/
isOverride?: boolean | null;
};
export type ListLLMPricingRules200 = {
@@ -9746,6 +9746,19 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type GetStats200Data = { [key: string]: unknown };
export type GetStats200 = {
/**
* @type object
*/
data: GetStats200Data;
/**
* @type string
*/
status: string;
};
export type GetTraceAggregationsPathParameters = {
traceID: string;
};

View File

@@ -0,0 +1,96 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useQuery } from 'react-query';
import type {
InvalidateOptions,
QueryClient,
QueryFunction,
QueryKey,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type { GetStats200, RenderErrorResponseDTO } from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
/**
* This endpoint returns the collected stats for the organization
* @summary Get stats
*/
export const getStats = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetStats200>({
url: `/api/v1/stats`,
method: 'GET',
signal,
});
};
export const getGetStatsQueryKey = () => {
return [`/api/v1/stats`] as const;
};
export const getGetStatsQueryOptions = <
TData = Awaited<ReturnType<typeof getStats>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getStats>>, TError, TData>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetStatsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getStats>>> = ({
signal,
}) => getStats(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getStats>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetStatsQueryResult = NonNullable<
Awaited<ReturnType<typeof getStats>>
>;
export type GetStatsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get stats
*/
export function useGetStats<
TData = Awaited<ReturnType<typeof getStats>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getStats>>, TError, TData>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetStatsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get stats
*/
export const invalidateGetStats = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetStatsQueryKey() },
options,
);
return queryClient;
};

View File

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

View File

@@ -1,7 +1,6 @@
import type { MessageContext } from 'api/ai-assistant/chat';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { deserialize } from 'lib/compositeQuery/serializer';
import { matchPath } from 'react-router-dom';
/**
@@ -295,11 +294,11 @@ function collectSharedMetadata(
// Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`.
const compositeQueryRaw = params.get(QueryParams.compositeQuery);
if (compositeQueryRaw) {
// Decode through the serializer seam (handles every tier + malformed
// input → null); never JSON.parse the raw URL value.
const decodedQuery = deserialize(compositeQueryRaw);
if (decodedQuery) {
out.query = decodedQuery;
try {
out.query = JSON.parse(decodeURIComponent(compositeQueryRaw));
} catch {
// Malformed JSON in the URL — drop silently rather than throw
// inside a context-collection helper.
}
}

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

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

@@ -35,7 +35,6 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
import { normalizeTimeToMs } from 'utils/timeUtils';
import { serialize } from 'lib/compositeQuery/serializer';
import { v4 as uuid } from 'uuid';
import AutoRefresh from '../AutoRefreshV2';
@@ -300,7 +299,7 @@ function DateTimeSelection({
})),
},
};
return serialize(updatedCompositeQuery);
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
}, [currentQuery]);
const onSelectHandler = useCallback(

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { deserialize } from 'lib/compositeQuery/serializer';
import { isEqual } from 'lodash-es';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
interface NavigateOptions {
@@ -39,20 +38,16 @@ const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
return false;
}
// Decode through the serializer seam: the URL value may be in any
// tier (legacy JSON or a tagged format), so never JSON.parse it raw.
const decoded1 = deserialize(query1);
const decoded2 = deserialize(query2);
const decoded1 = JSON.parse(decodeURIComponent(query1));
const decoded2 = JSON.parse(decodeURIComponent(query2));
if (!decoded1 || !decoded2) {
return false;
}
const filtered1 = cloneDeep(decoded1);
const filtered2 = cloneDeep(decoded2);
// Ignore the volatile `id` when comparing queries.
const { id: _id1, ...rest1 } = decoded1;
const { id: _id2, ...rest2 } = decoded2;
delete filtered1.id;
delete filtered2.id;
return isEqual(rest1, rest2);
return isEqual(filtered1, filtered2);
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
return false;

View File

@@ -1,213 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
//
// ╔════════════════════════════════════════════════════════════════════════════╗
// ║ ⚠️ NEVER UPDATE THESE ⚠️ ║
// ╠════════════════════════════════════════════════════════════════════════════╣
// ║ These snapshots guard URL backward compatibility. Every emitted URL ║
// ║ encodes a diff against these exact baselines. ║
// ║ ║
// ║ If a test fails: REVERT baseline.ts, do NOT update snapshots. ║
// ║ If you need a new schema: create BASELINE_V2 + new adapter prefix. ║
// ╚════════════════════════════════════════════════════════════════════════════╝
exports[`baseline immutability snapshots LOGS_BASELINE must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots METRICS_BASELINE must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "noop",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots TRACES_BASELINE must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;

View File

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

View File

@@ -1,40 +0,0 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { deserialize, serialize } from 'lib/compositeQuery/serializer';
describe('composite query serializer', () => {
it('serialize picks shortest format', () => {
const query = initialQueriesMap.metrics;
const serialized = serialize(query);
const jsonSerialized = encodeURIComponent(JSON.stringify(query));
// Serializer should pick a format shorter than or equal to raw JSON
expect(serialized.length).toBeLessThanOrEqual(jsonSerialized.length);
// Should use a tagged format with baseline indicator (m/l/t)
const usesTaggedFormat =
/^V1[mlt]~/.test(serialized) ||
/^TV[mlt]~/.test(serialized) ||
/^FV[mlt]~/.test(serialized) ||
/^FK[mlt]~/.test(serialized);
expect(usesTaggedFormat).toBe(true);
});
it('round-trips through serialize/deserialize', () => {
const query = initialQueriesMap.logs;
const decoded = deserialize(serialize(query));
expect(decoded?.builder.queryData[0].dataSource).toBe('logs');
});
it('returns null on corrupt input instead of throwing', () => {
expect(deserialize('%7Bnot-json')).toBeNull();
});
it('returns null for empty/missing value', () => {
expect(deserialize('')).toBeNull();
});
it('preserves id field through roundtrip', () => {
const query = { ...initialQueriesMap.metrics, id: 'test-query-uuid-123' };
const serialized = serialize(query);
const decoded = deserialize(serialized);
expect(decoded?.id).toBe('test-query-uuid-123');
});
});

View File

@@ -1,166 +0,0 @@
/**
* Flat-keys codec: a baseline-diff serializer that shortens keys but keeps
* values as raw JSON. Simpler than flat-leaf — no field-aware enum compression.
*
* Wire grammar (tokens joined by `*`):
* set: <shortPath>_<escapedJSON> e.g. b.qd.0.ds_"logs"
* delete: -<shortPath> e.g. -b.qd.0.ag.0.mn
*/
import getBaselineByTag, {
BaselineTag,
pickBaseline,
} from 'lib/compositeQuery/baseline';
import { INVERSE_KEY_MAP, KEY_MAP } from './maps';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
type Json = unknown;
type PathSeg = string | number;
const PAIR = '*';
const KV = '_';
const DEL = '-';
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
const isContainer = (value: Json): value is Record<string, Json> | Json[] =>
typeof value === 'object' && value !== null;
const isEmptyContainer = (value: Json): boolean =>
isContainer(value) &&
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
const isLeaf = (value: Json): boolean =>
!isContainer(value) || isEmptyContainer(value);
const shortenSeg = (seg: PathSeg): PathSeg =>
typeof seg === 'number' ? seg : (KEY_MAP[seg] ?? seg);
function leafMap(obj: Json): Record<string, Json> {
const out: Record<string, Json> = {};
const walk = (node: Json, segs: PathSeg[]): void => {
if (isLeaf(node)) {
out[segs.map(shortenSeg).join('.')] = node;
return;
}
if (Array.isArray(node)) {
node.forEach((value, index) => walk(value, [...segs, index]));
return;
}
Object.entries(node as Record<string, Json>).forEach(([key, value]) =>
walk(value, [...segs, key]),
);
};
walk(obj, []);
return out;
}
function escapeValue(str: string): string {
let escaped = str.replace(/_/g, '__').replace(/\*/g, '_s');
if (escaped[0] === '!') {
escaped = `_i${escaped.slice(1)}`;
}
return escaped;
}
const UNESCAPE: Record<string, string> = { _: '_', s: '*', i: '!' };
function unescapeValue(str: string): string {
let out = '';
for (let i = 0; i < str.length; i += 1) {
if (str[i] === '_') {
const next = str[i + 1];
out += UNESCAPE[next] ?? next;
i += 1;
} else {
out += str[i];
}
}
return out;
}
function encodeLeaf(value: Json): string {
if (value === undefined) {
return escapeValue('null');
}
return escapeValue(JSON.stringify(value));
}
function decodeLeaf(token: string): Json {
return JSON.parse(unescapeValue(token));
}
function parsePath(pathStr: string): PathSeg[] {
return pathStr
.split('.')
.map((seg) => (isIndex(seg) ? Number(seg) : (INVERSE_KEY_MAP[seg] ?? seg)));
}
function setPath(
root: Record<string, Json>,
segs: PathSeg[],
value: Json,
): void {
let node: Json = root;
for (let i = 0; i < segs.length - 1; i += 1) {
const seg = segs[i];
const container = node as Record<string | number, Json>;
if (!isContainer(container[seg])) {
container[seg] = typeof segs[i + 1] === 'number' ? [] : {};
}
node = container[seg];
}
(node as Record<string | number, Json>)[segs[segs.length - 1]] = value;
}
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
const root: Record<string, Json> = {};
Object.entries(map).forEach(([path, value]) => {
setPath(root, parsePath(path), value);
});
return root;
}
export function encode(query: Query): { payload: string; tag: BaselineTag } {
const { baseline, tag } = pickBaseline(query);
const base = leafMap(baseline);
const next = leafMap(query);
const tokens: string[] = [];
Object.entries(next).forEach(([path, value]) => {
const baseVal = base[path];
const normalizedBase = baseVal === undefined ? null : baseVal;
const normalizedNext = value === undefined ? null : value;
if (
path in base &&
JSON.stringify(normalizedBase) === JSON.stringify(normalizedNext)
) {
return;
}
tokens.push(`${path}${KV}${encodeLeaf(value)}`);
});
Object.keys(base).forEach((path) => {
if (!(path in next)) {
tokens.push(`${DEL}${path}`);
}
});
tokens.sort();
return { payload: tokens.join(PAIR), tag };
}
export function decode(payload: string, tag: BaselineTag): Query {
const map = leafMap(getBaselineByTag(tag));
if (payload) {
payload.split(PAIR).forEach((token) => {
if (!token) {
return;
}
if (token[0] === DEL) {
delete map[token.slice(1)];
return;
}
const sep = token.indexOf(KV);
const path = token.slice(0, sep);
map[path] = decodeLeaf(token.slice(sep + 1));
});
}
return rebuildFromLeaves(map) as unknown as Query;
}

View File

@@ -1,100 +0,0 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { roundTripScenarios } from '../testing/scenarios';
import { decodeFlatKeys, encodeFlatKeys, flatKeysAdapter } from './index';
const roundTrip = (query: Query): Query =>
flatKeysAdapter.decode(flatKeysAdapter.encode(query));
const clone = (query: Query): Query =>
JSON.parse(JSON.stringify(query)) as Query;
describe('flatKeysAdapter', () => {
describe('round-trip scenarios', () => {
it.each(roundTripScenarios)('$name', ({ query }) => {
expect(roundTrip(query)).toStrictEqual(query);
});
});
describe('tag matching', () => {
it('tags output with FKm~/FKl~/FKt~', () => {
const metricsEncoded = flatKeysAdapter.encode(initialQueriesMap.metrics);
expect(metricsEncoded.startsWith('FKm~')).toBe(true);
const logsEncoded = flatKeysAdapter.encode(initialQueriesMap.logs);
expect(logsEncoded.startsWith('FKl~')).toBe(true);
const tracesEncoded = flatKeysAdapter.encode(initialQueriesMap.traces);
expect(tracesEncoded.startsWith('FKt~')).toBe(true);
});
it('matches only own tags', () => {
const encoded = flatKeysAdapter.encode(initialQueriesMap.metrics);
expect(flatKeysAdapter.matches(encoded)).toBe(true);
expect(flatKeysAdapter.matches('FVm~')).toBe(false);
expect(flatKeysAdapter.matches('%7Bnot-mine')).toBe(false);
});
});
describe('baseline behavior', () => {
it('uses correct baseline tag per dataSource', () => {
expect(encodeFlatKeys(initialQueriesMap.logs).tag).toBe('l');
expect(encodeFlatKeys(initialQueriesMap.traces).tag).toBe('t');
expect(encodeFlatKeys(initialQueriesMap.metrics).tag).toBe('m');
});
it('decodeFlatKeys on empty payload returns baseline', () => {
const decodedMetrics = decodeFlatKeys('', 'm');
expect(decodedMetrics.queryType).toBe('builder');
expect(decodedMetrics.builder.queryData[0].dataSource).toBe('metrics');
const decodedLogs = decodeFlatKeys('', 'l');
expect(decodedLogs.builder.queryData[0].dataSource).toBe('logs');
});
});
describe('encoding stability', () => {
it('identical encoding after roundtrip', () => {
const query = initialQueriesMap.metrics;
const encoded1 = flatKeysAdapter.encode(query);
const decoded = flatKeysAdapter.decode(encoded1);
const encoded2 = flatKeysAdapter.encode(decoded);
expect(encoded2).toBe(encoded1);
});
it('key order independent', () => {
const query1 = initialQueriesMap.metrics;
const query2 = JSON.parse(JSON.stringify(query1)) as Query;
const reordered = {
unit: query2.unit,
id: query2.id,
queryType: query2.queryType,
clickhouse_sql: query2.clickhouse_sql,
promql: query2.promql,
builder: query2.builder,
} as Query;
const encoded1 = flatKeysAdapter.encode(query1);
const encoded2 = flatKeysAdapter.encode(reordered);
expect(encoded2).toBe(encoded1);
});
});
describe('undefined handling', () => {
it('handles undefined values without breaking decode', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).aggregateOperator = undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).source = undefined;
const encoded = flatKeysAdapter.encode(query);
const decoded = flatKeysAdapter.decode(encoded);
expect(decoded).not.toBeNull();
});
});
});

View File

@@ -1,32 +0,0 @@
import { BaselineTag } from 'lib/compositeQuery/baseline';
import { decode, encode } from './codec';
import { CompositeQueryAdapter } from './types';
const TAG_PREFIX = 'FK';
const TAG_SUFFIX = '~';
/**
* Flat-keys (FK~): stores a query as a keys-only, leaf-flattened diff from the
* frozen baseline. Uses short keys but keeps values as raw JSON (no field-aware
* enum compression).
*
* Useful for benchmarking: isolates contribution of key shortening from value compression.
*/
export const flatKeysAdapter: CompositeQueryAdapter = {
name: 'flat-keys',
encode: (query) => {
const { payload, tag } = encode(query);
return `${TAG_PREFIX}${tag}${TAG_SUFFIX}${payload}`;
},
matches: (raw) =>
raw.startsWith(`${TAG_PREFIX}m${TAG_SUFFIX}`) ||
raw.startsWith(`${TAG_PREFIX}l${TAG_SUFFIX}`) ||
raw.startsWith(`${TAG_PREFIX}t${TAG_SUFFIX}`),
decode: (raw) => {
const tag = raw[2] as BaselineTag;
const payload = raw.slice(4);
return decode(payload, tag);
},
};
export { encode as encodeFlatKeys, decode as decodeFlatKeys } from './codec';

View File

@@ -1,51 +0,0 @@
/**
* Key shortening map for flatKeys adapter.
* No value compression - only key shortening.
*/
export const KEY_MAP: Record<string, string> = {
queryType: 'qt',
builder: 'b',
queryData: 'qd',
queryFormulas: 'qf',
queryTraceOperator: 'qo',
dataSource: 'ds',
queryName: 'qn',
aggregateOperator: 'ao',
aggregateAttribute: 'aa',
timeAggregation: 'ta',
spaceAggregation: 'sa',
filter: 'f',
expression: 'e',
aggregations: 'ag',
functions: 'fn',
filters: 'fl',
items: 'i',
disabled: 'd',
stepInterval: 'si',
having: 'h',
limit: 'l',
orderBy: 'ob',
groupBy: 'gb',
legend: 'lg',
reduceTo: 'rt',
source: 's',
promql: 'pq',
clickhouse_sql: 'cs',
name: 'n',
query: 'q',
key: 'k',
dataType: 'dt',
type: 't',
metricName: 'mn',
temporality: 'tp',
columnName: 'cn',
order: 'o',
value: 'v',
op: 'op',
isColumn: 'ic',
isJSON: 'ij',
};
export const INVERSE_KEY_MAP: Record<string, string> = Object.fromEntries(
Object.entries(KEY_MAP).map(([long, short]) => [short, long]),
);

View File

@@ -1,8 +0,0 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export interface CompositeQueryAdapter {
readonly name: string;
encode(query: Query): string;
matches(raw: string): boolean;
decode(raw: string): Query;
}

View File

@@ -1,197 +0,0 @@
/**
* Flat-leaf codec: a baseline-diff serializer that emits one token per changed
* scalar leaf, with no JSON wrapper characters on the wire.
*
* Both the baseline and the query are flattened to maps of
* `{ shortDotPath: scalarLeaf }` (arrays walked element-wise, so `queryData[0]`
* becomes `qd.0`). Encode diffs the two maps and emits a token per changed or
* removed leaf; decode replays those tokens onto the baseline map and rebuilds
* the nested object. Because arrays are walked element-wise, adding one filter
* costs a few scalar tokens instead of a whole-array JSON blob.
*
* Wire grammar (tokens joined by `*`):
* set: <shortPath>_<encodedLeaf> e.g. b.qd.0.ds_0
* delete: -<shortPath> e.g. -b.qd.0.ag.0.mn
*/
import getBaselineByTag, {
BaselineTag,
pickBaseline,
} from 'lib/compositeQuery/baseline';
import {
FIELD_DOMAINS,
INVERSE_FIELD_DOMAINS,
INVERSE_KEY_MAP,
KEY_MAP,
} from './maps';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
type Json = unknown;
type PathSeg = string | number;
const PAIR = '*';
const KV = '_';
const DEL = '-';
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
const isContainer = (value: Json): value is Record<string, Json> | Json[] =>
typeof value === 'object' && value !== null;
const isEmptyContainer = (value: Json): boolean =>
isContainer(value) &&
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
const isLeaf = (value: Json): boolean =>
!isContainer(value) || isEmptyContainer(value);
const shortenSeg = (seg: PathSeg): PathSeg =>
typeof seg === 'number' ? seg : (KEY_MAP[seg] ?? seg);
const lastSeg = (path: string): string => path.slice(path.lastIndexOf('.') + 1);
function leafMap(obj: Json): Record<string, Json> {
const out: Record<string, Json> = {};
const walk = (node: Json, segs: PathSeg[]): void => {
if (isLeaf(node)) {
out[segs.map(shortenSeg).join('.')] = node;
return;
}
if (Array.isArray(node)) {
node.forEach((value, index) => walk(value, [...segs, index]));
return;
}
Object.entries(node as Record<string, Json>).forEach(([key, value]) =>
walk(value, [...segs, key]),
);
};
walk(obj, []);
return out;
}
function escapeValue(str: string): string {
let escaped = str.replace(/_/g, '__').replace(/\*/g, '_s');
if (escaped[0] === '!') {
escaped = `_i${escaped.slice(1)}`;
}
return escaped;
}
const UNESCAPE: Record<string, string> = { _: '_', s: '*', i: '!' };
function unescapeValue(str: string): string {
let out = '';
for (let i = 0; i < str.length; i += 1) {
if (str[i] === '_') {
const next = str[i + 1];
out += UNESCAPE[next] ?? next;
i += 1;
} else {
out += str[i];
}
}
return out;
}
function encodeLeaf(value: Json, shortKey: string): string {
if (typeof value === 'string') {
const domain = FIELD_DOMAINS[shortKey];
if (domain?.[value] !== undefined) {
return String(domain[value]);
}
return escapeValue(value);
}
if (value === undefined) {
return '!null';
}
return `!${JSON.stringify(value)}`;
}
function decodeLeaf(token: string, shortKey: string): Json {
if (token[0] === '!') {
return JSON.parse(token.slice(1));
}
const inverse = INVERSE_FIELD_DOMAINS[shortKey];
if (inverse && isIndex(token)) {
const mapped = inverse[Number(token)];
if (mapped !== undefined) {
return mapped;
}
}
return unescapeValue(token);
}
function parsePath(pathStr: string): PathSeg[] {
return pathStr
.split('.')
.map((seg) => (isIndex(seg) ? Number(seg) : (INVERSE_KEY_MAP[seg] ?? seg)));
}
function setPath(
root: Record<string, Json>,
segs: PathSeg[],
value: Json,
): void {
let node: Json = root;
for (let i = 0; i < segs.length - 1; i += 1) {
const seg = segs[i];
const container = node as Record<string | number, Json>;
if (!isContainer(container[seg])) {
container[seg] = typeof segs[i + 1] === 'number' ? [] : {};
}
node = container[seg];
}
(node as Record<string | number, Json>)[segs[segs.length - 1]] = value;
}
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
const root: Record<string, Json> = {};
Object.entries(map).forEach(([path, value]) => {
setPath(root, parsePath(path), value);
});
return root;
}
export function encode(query: Query): { payload: string; tag: BaselineTag } {
const { baseline, tag } = pickBaseline(query);
const base = leafMap(baseline);
const next = leafMap(query);
const tokens: string[] = [];
Object.entries(next).forEach(([path, value]) => {
const baseVal = base[path];
const normalizedBase = baseVal === undefined ? null : baseVal;
const normalizedNext = value === undefined ? null : value;
if (
path in base &&
JSON.stringify(normalizedBase) === JSON.stringify(normalizedNext)
) {
return;
}
tokens.push(`${path}${KV}${encodeLeaf(value, lastSeg(path))}`);
});
Object.keys(base).forEach((path) => {
if (!(path in next)) {
tokens.push(`${DEL}${path}`);
}
});
tokens.sort();
return { payload: tokens.join(PAIR), tag };
}
export function decode(payload: string, tag: BaselineTag): Query {
const map = leafMap(getBaselineByTag(tag));
if (payload) {
payload.split(PAIR).forEach((token) => {
if (!token) {
return;
}
if (token[0] === DEL) {
delete map[token.slice(1)];
return;
}
const sep = token.indexOf(KV);
const path = token.slice(0, sep);
map[path] = decodeLeaf(token.slice(sep + 1), lastSeg(path));
});
}
return rebuildFromLeaves(map) as unknown as Query;
}

View File

@@ -1,151 +0,0 @@
import { isEqual } from 'lodash-es';
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { roundTripScenarios } from '../testing/scenarios';
import { decodeFlatLeaf, encodeFlatLeaf, flatLeafAdapter } from './index';
const roundTrip = (query: Query): Query =>
flatLeafAdapter.decode(flatLeafAdapter.encode(query));
const clone = (query: Query): Query =>
JSON.parse(JSON.stringify(query)) as Query;
describe('flatLeafAdapter', () => {
describe('round-trip scenarios', () => {
it.each(roundTripScenarios)('$name', ({ query }) => {
expect(roundTrip(query)).toStrictEqual(query);
});
});
describe('tag matching', () => {
it('tags output with FVm~/FVl~/FVt~', () => {
const metricsEncoded = flatLeafAdapter.encode(initialQueriesMap.metrics);
expect(metricsEncoded.startsWith('FVm~')).toBe(true);
const logsEncoded = flatLeafAdapter.encode(initialQueriesMap.logs);
expect(logsEncoded.startsWith('FVl~')).toBe(true);
const tracesEncoded = flatLeafAdapter.encode(initialQueriesMap.traces);
expect(tracesEncoded.startsWith('FVt~')).toBe(true);
});
it('matches only own tags', () => {
const encoded = flatLeafAdapter.encode(initialQueriesMap.metrics);
expect(flatLeafAdapter.matches(encoded)).toBe(true);
expect(flatLeafAdapter.matches('V1m~[]')).toBe(false);
expect(flatLeafAdapter.matches('%7Bnot-mine')).toBe(false);
});
});
describe('baseline behavior', () => {
it('uses correct baseline tag per dataSource', () => {
expect(encodeFlatLeaf(initialQueriesMap.logs).tag).toBe('l');
expect(encodeFlatLeaf(initialQueriesMap.traces).tag).toBe('t');
expect(encodeFlatLeaf(initialQueriesMap.metrics).tag).toBe('m');
});
it('emits no payload when query equals baseline shape', () => {
const { payload, tag } = encodeFlatLeaf(initialQueriesMap.logs);
expect(tag).toBe('l');
expect(decodeFlatLeaf(payload, tag)).toStrictEqual(initialQueriesMap.logs);
});
it('decodeFlatLeaf on empty payload returns baseline', () => {
const decodedMetrics = decodeFlatLeaf('', 'm');
expect(decodedMetrics.queryType).toBe('builder');
expect(decodedMetrics.builder.queryData[0].dataSource).toBe('metrics');
const decodedLogs = decodeFlatLeaf('', 'l');
expect(decodedLogs.builder.queryData[0].dataSource).toBe('logs');
});
});
describe('encoding stability', () => {
it('identical encoding after roundtrip', () => {
const query = initialQueriesMap.metrics;
const encoded1 = flatLeafAdapter.encode(query);
const decoded = flatLeafAdapter.decode(encoded1);
const encoded2 = flatLeafAdapter.encode(decoded);
expect(encoded2).toBe(encoded1);
});
it('key order independent', () => {
const query1 = initialQueriesMap.metrics;
const query2 = JSON.parse(JSON.stringify(query1)) as Query;
const reordered = {
unit: query2.unit,
id: query2.id,
queryType: query2.queryType,
clickhouse_sql: query2.clickhouse_sql,
promql: query2.promql,
builder: query2.builder,
} as Query;
const encoded1 = flatLeafAdapter.encode(query1);
const encoded2 = flatLeafAdapter.encode(reordered);
expect(encoded2).toBe(encoded1);
});
it('stable after spread/reconstruct', () => {
const query = { ...initialQueriesMap.metrics };
const encoded1 = flatLeafAdapter.encode(query);
const transformed = {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item) => ({
...item,
})),
},
};
const encoded2 = flatLeafAdapter.encode(transformed);
expect(encoded2).toBe(encoded1);
});
});
describe('key validation', () => {
it('decoded query has all keys expected by replaceIncorrectObjectFields', () => {
const decoded = flatLeafAdapter.decode(
flatLeafAdapter.encode(initialQueriesMap.metrics),
);
const decodedKeys = Object.keys(decoded).sort();
const expectedKeys = Object.keys(initialQueriesMap.metrics).sort();
expect(decodedKeys).toStrictEqual(expectedKeys);
});
});
describe('isEqual compatibility', () => {
it('lodash isEqual works for decoded queries', () => {
const query = initialQueriesMap.metrics;
const encoded = flatLeafAdapter.encode(query);
const decoded = flatLeafAdapter.decode(encoded);
const { id: _id1, ...rest1 } = query;
const { id: _id2, ...rest2 } = decoded;
expect(isEqual(rest1, rest2)).toBe(true);
});
});
describe('undefined handling', () => {
it('handles undefined values without breaking decode', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).aggregateOperator = undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).source = undefined;
const encoded = flatLeafAdapter.encode(query);
const decoded = flatLeafAdapter.decode(encoded);
expect(decoded).not.toBeNull();
});
});
});

View File

@@ -1,30 +0,0 @@
import { BaselineTag } from 'lib/compositeQuery/baseline';
import { decode, encode } from './codec';
import { CompositeQueryAdapter } from './types';
const TAG_PREFIX = 'FV';
const TAG_SUFFIX = '~';
/**
* Flat-leaf (FV~): stores a query as a field-aware, leaf-flattened diff from the
* frozen baseline. Uses the smart hybrid strategy — metrics or logs baseline
* picked by dataSource — so the tag is FVm~ (metrics) or FVl~ (logs).
*/
export const flatLeafAdapter: CompositeQueryAdapter = {
name: 'flat-leaf',
encode: (query) => {
const { payload, tag } = encode(query);
return `${TAG_PREFIX}${tag}${TAG_SUFFIX}${payload}`;
},
matches: (raw) =>
raw.startsWith(`${TAG_PREFIX}m${TAG_SUFFIX}`) ||
raw.startsWith(`${TAG_PREFIX}l${TAG_SUFFIX}`) ||
raw.startsWith(`${TAG_PREFIX}t${TAG_SUFFIX}`),
decode: (raw) => {
const tag = raw[2] as BaselineTag;
const payload = raw.slice(4);
return decode(payload, tag);
},
};
export { encode as encodeFlatLeaf, decode as decodeFlatLeaf } from './codec';

View File

@@ -1,197 +0,0 @@
import {
MetrictypesSpaceAggregationDTO,
MetrictypesTemporalityDTO,
MetrictypesTimeAggregationDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
AutocompleteType,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { EQueryType } from 'types/common/dashboard';
import {
DataSource,
LogsAggregatorOperator,
MetricAggregateOperator,
ReduceOperators,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
export const KEY_MAP: Record<string, string> = {
queryType: 'qt',
builder: 'b',
queryData: 'qd',
queryFormulas: 'qf',
queryTraceOperator: 'qo',
dataSource: 'ds',
queryName: 'qn',
aggregateOperator: 'ao',
aggregateAttribute: 'aa',
timeAggregation: 'ta',
spaceAggregation: 'sa',
filter: 'f',
expression: 'e',
aggregations: 'ag',
functions: 'fn',
filters: 'fl',
items: 'i',
disabled: 'd',
stepInterval: 'si',
having: 'h',
limit: 'l',
orderBy: 'ob',
groupBy: 'gb',
legend: 'lg',
reduceTo: 'rt',
source: 's',
promql: 'pq',
clickhouse_sql: 'cs',
name: 'n',
query: 'q',
key: 'k',
dataType: 'dt',
type: 't',
metricName: 'mn',
temporality: 'tp',
columnName: 'cn',
order: 'o',
value: 'v',
op: 'op',
isColumn: 'ic',
isJSON: 'ij',
};
export const INVERSE_KEY_MAP: Record<string, string> = Object.fromEntries(
Object.entries(KEY_MAP).map(([long, short]) => [short, long]),
);
const DATA_SOURCE: Record<DataSource, number> = {
[DataSource.LOGS]: 0,
[DataSource.METRICS]: 1,
[DataSource.TRACES]: 2,
};
const QUERY_TYPE: Record<EQueryType, number> = {
[EQueryType.QUERY_BUILDER]: 0,
[EQueryType.PROM]: 1,
[EQueryType.CLICKHOUSE]: 2,
};
const REDUCE_TO: Record<ReduceOperators, number> = {
[ReduceOperators.LAST]: 0,
[ReduceOperators.SUM]: 1,
[ReduceOperators.AVG]: 2,
[ReduceOperators.MAX]: 3,
[ReduceOperators.MIN]: 4,
};
const TEMPORALITY: Record<MetrictypesTemporalityDTO, number> = {
[MetrictypesTemporalityDTO.delta]: 0,
[MetrictypesTemporalityDTO.cumulative]: 1,
[MetrictypesTemporalityDTO.unspecified]: 2,
};
const TIME_AGGREGATION: Record<MetrictypesTimeAggregationDTO, number> = {
[MetrictypesTimeAggregationDTO.latest]: 0,
[MetrictypesTimeAggregationDTO.sum]: 1,
[MetrictypesTimeAggregationDTO.avg]: 2,
[MetrictypesTimeAggregationDTO.min]: 3,
[MetrictypesTimeAggregationDTO.max]: 4,
[MetrictypesTimeAggregationDTO.count]: 5,
[MetrictypesTimeAggregationDTO.count_distinct]: 6,
[MetrictypesTimeAggregationDTO.rate]: 7,
[MetrictypesTimeAggregationDTO.increase]: 8,
};
const SPACE_AGGREGATION: Record<MetrictypesSpaceAggregationDTO, number> = {
[MetrictypesSpaceAggregationDTO.sum]: 0,
[MetrictypesSpaceAggregationDTO.avg]: 1,
[MetrictypesSpaceAggregationDTO.min]: 2,
[MetrictypesSpaceAggregationDTO.max]: 3,
[MetrictypesSpaceAggregationDTO.count]: 4,
[MetrictypesSpaceAggregationDTO.p50]: 5,
[MetrictypesSpaceAggregationDTO.p75]: 6,
[MetrictypesSpaceAggregationDTO.p90]: 7,
[MetrictypesSpaceAggregationDTO.p95]: 8,
[MetrictypesSpaceAggregationDTO.p99]: 9,
};
const AGGREGATE_OPERATOR: Record<
MetricAggregateOperator | TracesAggregatorOperator | LogsAggregatorOperator,
number
> = {
[MetricAggregateOperator.EMPTY]: 0,
[MetricAggregateOperator.NOOP]: 1,
[MetricAggregateOperator.COUNT]: 2,
[MetricAggregateOperator.COUNT_DISTINCT]: 3,
[MetricAggregateOperator.SUM]: 4,
[MetricAggregateOperator.AVG]: 5,
[MetricAggregateOperator.MAX]: 6,
[MetricAggregateOperator.MIN]: 7,
[MetricAggregateOperator.P05]: 8,
[MetricAggregateOperator.P10]: 9,
[MetricAggregateOperator.P20]: 10,
[MetricAggregateOperator.P25]: 11,
[MetricAggregateOperator.P50]: 12,
[MetricAggregateOperator.P75]: 13,
[MetricAggregateOperator.P90]: 14,
[MetricAggregateOperator.P95]: 15,
[MetricAggregateOperator.P99]: 16,
[MetricAggregateOperator.RATE]: 17,
[MetricAggregateOperator.SUM_RATE]: 18,
[MetricAggregateOperator.AVG_RATE]: 19,
[MetricAggregateOperator.MAX_RATE]: 20,
[MetricAggregateOperator.MIN_RATE]: 21,
[MetricAggregateOperator.RATE_SUM]: 22,
[MetricAggregateOperator.RATE_AVG]: 23,
[MetricAggregateOperator.RATE_MIN]: 24,
[MetricAggregateOperator.RATE_MAX]: 25,
[MetricAggregateOperator.HIST_QUANTILE_50]: 26,
[MetricAggregateOperator.HIST_QUANTILE_75]: 27,
[MetricAggregateOperator.HIST_QUANTILE_90]: 28,
[MetricAggregateOperator.HIST_QUANTILE_95]: 29,
[MetricAggregateOperator.HIST_QUANTILE_99]: 30,
[MetricAggregateOperator.INCREASE]: 31,
[MetricAggregateOperator.LATEST]: 32,
};
const DATA_TYPE: Record<DataTypes, number> = {
[DataTypes.Int64]: 0,
[DataTypes.String]: 1,
[DataTypes.Float64]: 2,
[DataTypes.bool]: 3,
[DataTypes.ArrayFloat64]: 4,
[DataTypes.ArrayInt64]: 5,
[DataTypes.ArrayString]: 6,
[DataTypes.ArrayBool]: 7,
[DataTypes.EMPTY]: 8,
};
const ATTR_TYPE: Record<AutocompleteType, number> = {
tag: 0,
resource: 1,
'': 2,
};
export const FIELD_DOMAINS: Record<string, Record<string, number>> = {
ds: DATA_SOURCE,
qt: QUERY_TYPE,
rt: REDUCE_TO,
tp: TEMPORALITY,
ta: TIME_AGGREGATION,
sa: SPACE_AGGREGATION,
ao: AGGREGATE_OPERATOR,
dt: DATA_TYPE,
t: ATTR_TYPE,
};
export const INVERSE_FIELD_DOMAINS: Record<
string,
Record<number, string>
> = Object.fromEntries(
Object.entries(FIELD_DOMAINS).map(([field, domain]) => [
field,
Object.fromEntries(
Object.entries(domain).map(([str, int]) => [int, str]),
) as Record<number, string>,
]),
);

View File

@@ -1,8 +0,0 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export interface CompositeQueryAdapter {
readonly name: string;
encode(query: Query): string;
matches(raw: string): boolean;
decode(raw: string): Query;
}

View File

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

View File

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

View File

@@ -1,8 +0,0 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export interface CompositeQueryAdapter {
readonly name: string;
encode(query: Query): string;
matches(raw: string): boolean;
decode(raw: string): Query;
}

View File

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

View File

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

View File

@@ -1,28 +0,0 @@
import { jsonAdapter } from 'lib/compositeQuery/adapters/json';
import { CompositeQueryAdapter } from 'lib/compositeQuery/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { flatKeysAdapter } from 'lib/compositeQuery/adapters/flatKeys';
// Order matters for decode: most-specific (tagged) adapters first, json last
const ADAPTERS: CompositeQueryAdapter[] = [flatKeysAdapter, jsonAdapter];
/** Encode a query to the shortest available URL value. */
export function serialize(query: Query): string {
return ADAPTERS.map((adapter) => adapter.encode(query)).reduce(
(shortest, candidate) =>
candidate.length < shortest.length ? candidate : shortest,
);
}
/** Decode a URL value back to a Query. Total: returns null on any failure. */
export function deserialize(raw: string): Query | null {
if (!raw) {
return null;
}
try {
const adapter = ADAPTERS.find((item) => item.matches(raw)) ?? jsonAdapter;
return adapter.decode(raw);
} catch {
return null;
}
}

View File

@@ -1,14 +0,0 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* A serialization tier. `encode` returns the COMPLETE URL value (tag prefix
* included for tagged tiers). `matches` decides whether a raw value belongs to
* this adapter on decode. `decode` receives the COMPLETE raw value and strips
* its own tag.
*/
export interface CompositeQueryAdapter {
readonly name: string;
encode(query: Query): string;
matches(raw: string): boolean;
decode(raw: string): Query;
}

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

@@ -1,45 +1,91 @@
import { useMemo } from 'react';
import { Braces, Globe, Table } from '@signozhq/icons';
import { TabItemProps, 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';
interface DashboardSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
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 { user } = useAppContext();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
const items: TabItemProps[] = useMemo(
() => [
{
key: 'general',
label: 'General',
children: <GeneralSettings dashboard={dashboard} />,
prefixIcon: <Table size={14} />,
key: TabKeys.OVERVIEW,
label: TabKeys.OVERVIEW,
children: <Overview dashboard={dashboard} />,
},
{
key: 'variables',
label: 'Variables',
key: TabKeys.VARIABLES,
label: TabKeys.VARIABLES,
children: <VariablesSettings dashboard={dashboard} />,
prefixIcon: <Braces size={14} />,
},
{
key: 'public-dashboard',
label: 'Publish',
children: (
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
),
prefixIcon: <Globe 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

@@ -38,7 +38,6 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { serialize } from 'lib/compositeQuery/serializer';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
@@ -937,7 +936,7 @@ export function QueryBuilderProvider({
);
const { safeNavigate } = useSafeNavigate({
preventSameUrlNavigation: true,
preventSameUrlNavigation: false,
});
const redirectWithQueryBuilderData = useCallback(
@@ -991,7 +990,10 @@ export function QueryBuilderProvider({
);
}
urlQuery.set(QueryParams.compositeQuery, serialize(currentGeneratedQuery));
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(currentGeneratedQuery)),
);
if (searchParams) {
Object.keys(searchParams).forEach((param) =>

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

@@ -31,6 +31,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/zeus"
@@ -70,6 +71,7 @@ type provider struct {
traceDetailHandler tracedetail.Handler
rulerHandler ruler.Handler
llmPricingRuleHandler llmpricingrule.Handler
statsHandler statsreporter.Handler
}
func NewFactory(
@@ -102,6 +104,7 @@ func NewFactory(
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(
@@ -137,6 +140,7 @@ func NewFactory(
llmPricingRuleHandler,
traceDetailHandler,
rulerHandler,
statsHandler,
)
})
}
@@ -174,6 +178,7 @@ func newProvider(
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
@@ -210,6 +215,7 @@ func newProvider(
traceDetailHandler: traceDetailHandler,
rulerHandler: rulerHandler,
llmPricingRuleHandler: llmPricingRuleHandler,
statsHandler: statsHandler,
}
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
@@ -334,6 +340,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addStatsReporterRoutes(router); err != nil {
return err
}
return nil
}

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