Compare commits

...

20 Commits

Author SHA1 Message Date
Yunus M
74d5d0fd38 feat: streamline error handling with resolveAssistantErrorMessage utility 2026-06-15 20:57:53 +05:30
Yunus M
a9c13f41de fix: reduce size of fixed footer when assistant side panel is open 2026-06-15 20:40:50 +05:30
Yunus M
9372eb955d fix: show Noz in dashboard edit panel view 2026-06-15 20:25:48 +05:30
Yunus M
4392364fd3 feat: links should open in new tab 2026-06-15 20:17:50 +05:30
Yunus M
cc6605b64e feat: show current alert name in context in alert details page 2026-06-15 20:10:44 +05:30
Yunus M
94b475794e fix: wire open view and open channel flows 2026-06-15 19:30:06 +05:30
Ashwin Bhatkal
76ab0f25c0 feat(dashboard-v2): consolidate dashboard detail header, toolbar & settings (#11713)
* feat(dashboard-v2): consolidate dashboard page header & breadcrumbs

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

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

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

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

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

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

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

* chore: filter metric groups

* chore: filter metric groups

* chore: formula correction

* chore: added step flooring note

* chore: comment correction

* chore: comment correction

* chore: removed function

* chore: renamed variables

* chore: added happy test

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

* chore: added filter test 4

* chore: added 5th test for filterByStatus

* chore: added group by tests

* chore: pagination test added

* chore: added validation tests

* chore: added auth test

* chore: added all tests

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

* chore: added nodes integration test suite

* chore: namespaces integration tests

* ci: register inframonitoring suite + ruff format 01_hosts

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

* chore: added integration tests for clusters

* chore: formatting changed

* chore: formatting changed

* chore: formatting changed

* chore: added volumes integration tests

* chore: added deployments

* chore: added tests

* chore: added integration tests for jobs api

* chore: added order by host.name test

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

* refactor: code cleanup

* fix: upcoming check for recurring maintenances

* fix: remove recurrence.startTime/endTime usages

* fix: use embedded timezone in start/end times

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

* refactor: remove redundant code

* fix: make startTime a required field

* test: cover fixed schedule active window in IsActive

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

* test: cover recurring schedule active window in IsActive

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

* chore: add justification for unreachable code

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

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

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

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

* fix: tests to use UTC instead of utc

* fix: return proper errors from schedule.Unmarshal

* fix: copy schedule type to migration

* chore: move tz conversion to checkX methods

---------

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

* fix(workspace): fix callout overflow and sign-out text color regression
2026-06-12 20:11:29 +00:00
SagarRajput-7
30f52ecb6d fix(settings): guard against non-APIError in logs retention error state (#11685) 2026-06-12 18:57:03 +00:00
Vinicius Lourenço
629d24547c fix(infra-monitoring-volumes): add missing inodes columns (#11683)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* refactor(volumes): remove volume prefix

* fix(volumes): add missing inode columns

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

* fix: make py-fmt

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

* fix(infra-monitoring-details): ensure events/traces uses timestamp adjusted by the timezone
2026-06-12 12:22:33 +00:00
Vinicius Lourenço
76e7e88641 fix(infra-monitoring-clusters): deployments desired should use latest instead of avg (#11681) 2026-06-12 12:21:55 +00:00
Vinicius Lourenço
1b7954faaf fix(infra-monitoring-k8s-pods): working set memory should use space aggregation sum (#11680) 2026-06-12 12:03:43 +00:00
Naman Verma
6f79d6b18d test: use v1 dashboards list API in cleanup (#11688)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* test: check for response status in integration test before initiating cleanup

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

* feat: change normalise timestamp value from backend

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

* feat: api integration and type fixes

* feat: test updates

* feat: prevent flamegraph call till user pref arrive

* fix: keep flamegraph span ts in milli like others

* fix: remove fg span timestamp unit conversion

Since it's changed in api response

* feat: revert timestamp conversion to ms

---------

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

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

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

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

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

View File

@@ -409,10 +409,6 @@ components:
properties:
duration:
type: string
endTime:
format: date-time
nullable: true
type: string
repeatOn:
items:
$ref: '#/components/schemas/AlertmanagertypesRepeatOn'
@@ -420,11 +416,7 @@ components:
type: array
repeatType:
$ref: '#/components/schemas/AlertmanagertypesRepeatType'
startTime:
format: date-time
type: string
required:
- startTime
- duration
- repeatType
type: object
@@ -458,6 +450,7 @@ components:
type: string
required:
- timezone
- startTime
type: object
AuthtypesAttributeMapping:
properties:

View File

@@ -159,6 +159,25 @@ export interface CancelResponseDTO {
state: ExecutionStateDTO;
}
export interface ChipDTO {
/**
* @type string
* @description Stable chip id. Rule-engine chips use intent ids.
*/
id: string;
/**
* @type string
*/
text: string;
}
export interface ChipsResponseDTO {
/**
* @type array
*/
chips: ChipDTO[];
}
export type ClarificationFieldDTOOptions = string[] | null;
export type ClarificationFieldDTODefault = string | string[] | null;
@@ -386,15 +405,74 @@ export type ErrorBodyDTOErrors = ErrorResponseAdditionalDTO[] | null;
export type ErrorBodyDTOUrl = string | null;
/**
* Machine-readable error codes carried on ``ErrorBody.code``.
**Extensible set.** This enum is the single source of truth for every code
the backend can emit, on both the REST envelope and the SSE ``ErrorEvent``.
It is published in the OpenAPI schema (and therefore the generated TS
client) so clients get autocomplete and a typed discriminant. The set is
expected to *grow*: adding a member is a backward-compatible change (the
wire is still a plain JSON string), so clients MUST treat unknown codes
gracefully — branch on the codes they handle and keep a default fallback,
never hard-reject an unrecognized value. Re-exported from ``app.errors``
for convenience; ``AssistantError(code=...)`` requires a member of this
enum so a typo can never reach a client.
*/
export enum ErrorCodeDTO {
missing_signoz_url = 'missing_signoz_url',
invalid_signoz_url = 'invalid_signoz_url',
invalid_content_length = 'invalid_content_length',
invalid_fork_target = 'invalid_fork_target',
rate_limit_override_exceeds_ceiling = 'rate_limit_override_exceeds_ceiling',
thread_message_limit = 'thread_message_limit',
validation_error = 'validation_error',
missing_token = 'missing_token',
invalid_token = 'invalid_token',
permission_denied = 'permission_denied',
user_disabled = 'user_disabled',
org_disabled = 'org_disabled',
thread_not_found = 'thread_not_found',
message_not_found = 'message_not_found',
execution_not_found = 'execution_not_found',
approval_not_found = 'approval_not_found',
clarification_not_found = 'clarification_not_found',
action_metadata_not_found = 'action_metadata_not_found',
user_not_found = 'user_not_found',
region_not_configured = 'region_not_configured',
thread_busy = 'thread_busy',
thread_has_active_execution = 'thread_has_active_execution',
no_active_execution = 'no_active_execution',
approval_superseded = 'approval_superseded',
clarification_superseded = 'clarification_superseded',
undo_conflict = 'undo_conflict',
revert_conflict = 'revert_conflict',
revert_expired = 'revert_expired',
restore_expired = 'restore_expired',
connection_limit_exceeded = 'connection_limit_exceeded',
hourly_message_limit = 'hourly_message_limit',
daily_message_limit = 'daily_message_limit',
daily_token_limit = 'daily_token_limit',
daily_cost_limit = 'daily_cost_limit',
upstream_auth_error = 'upstream_auth_error',
max_turns_exceeded = 'max_turns_exceeded',
budget_exceeded = 'budget_exceeded',
agent_execution_error = 'agent_execution_error',
cli_not_found = 'cli_not_found',
cli_connection_error = 'cli_connection_error',
cli_process_error = 'cli_process_error',
sandbox_unavailable = 'sandbox_unavailable',
mcp_unavailable = 'mcp_unavailable',
internal_error = 'internal_error',
region_unreachable = 'region_unreachable',
heartbeat_expired = 'heartbeat_expired',
replay_unavailable = 'replay_unavailable',
}
/**
* Inner error object — matches Go ErrorsJSON.
*/
export interface ErrorBodyDTO {
/**
* @type string
* @pattern ^[a-z_]+$
*/
code: string;
code: ErrorCodeDTO;
/**
* @type string
*/
@@ -490,6 +568,23 @@ export type MessageActionDTOQuery = MessageActionDTOQueryAnyOf | null;
export type MessageActionDTOUrl = string | null;
/**
* Explorer namespace a saved view belongs to — its ``sourcePage``.
Mirrors the SigNoz product's saved-view ``sourcePage`` values so the
frontend can route an ``open_resource`` action for a view to the right
Explorer via its existing ``SOURCEPAGE_VS_ROUTES`` map. ``meter`` is the
Cost Meter Explorer and is intentionally distinct from ``metrics`` (the
product persists and lists meter views under ``sourcePage="meter"``).
*/
export enum SavedViewEntityDTO {
logs = 'logs',
traces = 'traces',
metrics = 'metrics',
meter = 'meter',
}
export type MessageActionDTOEntity = SavedViewEntityDTO | null;
export enum MessageActionKindDTO {
undo = 'undo',
revert = 'revert',
@@ -500,7 +595,7 @@ export enum MessageActionKindDTO {
apply_filter = 'apply_filter',
}
/**
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url.
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url. open_resource for a saved view also carries entity (logs/traces/metrics/meter) so the frontend routes to the correct Explorer.
*/
export interface MessageActionDTO {
kind: MessageActionKindDTO;
@@ -517,6 +612,7 @@ export interface MessageActionDTO {
signal?: MessageActionDTOSignal;
query?: MessageActionDTOQuery;
url?: MessageActionDTOUrl;
entity?: MessageActionDTOEntity;
}
export enum MessageContentTypeDTO {
@@ -590,6 +686,26 @@ export interface MessageSummaryDTO {
updatedAt: string;
}
export enum PageTypeDTO {
homepage = 'homepage',
dashboard_detail = 'dashboard_detail',
dashboard_list = 'dashboard_list',
panel_edit = 'panel_edit',
panel_fullscreen = 'panel_fullscreen',
logs_explorer = 'logs_explorer',
log_detail = 'log_detail',
traces_explorer = 'traces_explorer',
trace_detail = 'trace_detail',
metrics_explorer = 'metrics_explorer',
service_detail = 'service_detail',
services_list = 'services_list',
alert_edit = 'alert_edit',
alert_list = 'alert_list',
alert_new = 'alert_new',
alerts_triggered = 'alerts_triggered',
infra_entity_detail = 'infra_entity_detail',
other = 'other',
}
export enum ReadinessChecksDTODatabase {
ok = 'ok',
failed = 'failed',
@@ -990,8 +1106,10 @@ export type MessageActionEventDTOQuery = MessageActionEventDTOQueryAnyOf | null;
export type MessageActionEventDTOUrl = string | null;
export type MessageActionEventDTOEntity = SavedViewEntityDTO | null;
/**
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url.
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url. open_resource for a saved view also carries entity (logs/traces/metrics/meter) so the frontend routes to the correct Explorer.
*/
export interface MessageActionEventDTO {
kind: MessageActionKindDTO;
@@ -1008,6 +1126,7 @@ export interface MessageActionEventDTO {
signal?: MessageActionEventDTOSignal;
query?: MessageActionEventDTOQuery;
url?: MessageActionEventDTOUrl;
entity?: MessageActionEventDTOEntity;
}
export type MessageEventDTOActions = MessageActionEventDTO[] | null;
@@ -1385,3 +1504,21 @@ export type GetUsageApiV1AssistantUsageGetHeaders = {
*/
'X-SigNoz-URL'?: string | null;
};
export type GetChipsApiV1AssistantEmptyStateChipsGetParams = {
/**
* @description Frontend-declared page type. Typed as an enum, but unrecognized values are coerced to 'other' (not rejected) so a new frontend page type works before the backend knows it. The page type alone identifies the focused entity (e.g. trace_detail) for the 'Explain this …' chip; the agent reads the concrete entity from page context once a chip is clicked, so no separate entity id is needed.
*/
page_type: PageTypeDTO;
};
export type GetChipsApiV1AssistantEmptyStateChipsGetHeaders = {
/**
* @description SigNoz auth token (Bearer or raw JWT)
*/
authorization?: string | null;
/**
* @description SigNoz instance base URL for multi-tenant deployments. Falls back to SIGNOZ_API_URL env var when omitted.
*/
'X-SigNoz-URL'?: string | null;
};

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

View File

@@ -0,0 +1,24 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { ViewProps } from 'types/api/saveViews/types';
/**
* Fetches a single saved view by ID (`GET /api/v1/explorer/views/{viewId}`).
*
* Hand-maintained alongside the other `api/saveView/*` clients — explorer views
* are not in `docs/api/openapi.yml`, so Orval does not generate a hook here
* (unlike e.g. `useGetChannelByID` under `api/generated/services/channels`).
*
* Used by the AI assistant "Open view" action to load `compositeQuery` and
* navigate to the correct explorer without listing every view per source page.
* See `container/AIAssistant/components/ActionsSection/utils/openSavedView.ts`.
*/
export interface GetViewByIdProps {
status: string;
data: ViewProps;
}
export const getViewById = (
viewKey: string,
): Promise<AxiosResponse<GetViewByIdProps>> =>
axios.get(`/explorer/views/${viewKey}`);

View File

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

View File

@@ -0,0 +1,89 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { getAutoContexts } from '../getAutoContexts';
describe('getAutoContexts', () => {
it('returns alert detail context on alert overview with ruleId', () => {
const ruleId = 'rule-abc';
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.relativeTime}=1h`;
const contexts = getAutoContexts(ROUTES.ALERT_OVERVIEW, search);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: {
page: 'alert_detail',
ruleId,
},
},
]);
});
it('returns alert detail context on alert history with ruleId', () => {
const ruleId = 'rule-xyz';
const startTime = '1700000000000';
const endTime = '1700003600000';
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.startTime}=${startTime}&${QueryParams.endTime}=${endTime}`;
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, search);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: {
page: 'alert_detail',
ruleId,
timeRange: {
start: Number(startTime),
end: Number(endTime),
},
},
},
]);
});
it('returns triggered alerts context on alert history without ruleId', () => {
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, '');
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: {
page: 'alerts_triggered',
},
},
]);
});
it('returns dashboard detail context on dashboard page', () => {
const dashboardId = 'dash-123';
const pathname = ROUTES.DASHBOARD.replace(':dashboardId', dashboardId);
const contexts = getAutoContexts(pathname, '');
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'dashboard',
resourceId: dashboardId,
metadata: {
page: 'dashboard_detail',
},
},
]);
});
it('returns empty array on alert overview without ruleId', () => {
const contexts = getAutoContexts(ROUTES.ALERT_OVERVIEW, '');
expect(contexts).toStrictEqual([]);
});
});

View File

@@ -47,6 +47,15 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
resolveOpenResourceType,
resolveResourceId,
resolveSavedViewSourceHint,
} from './utils/resolveOpenResource';
import { ResourceType, resourceRoute } from './utils/resourceRoute';
import styles from './ActionsSection.module.scss';
interface ActionsSectionProps {
@@ -55,20 +64,6 @@ interface ActionsSectionProps {
messageId: string;
}
/**
* Resource-type strings the backend uses for `open_resource` and rollback
* actions. Centralized here so the route/module lookups below stay in sync.
*/
const ResourceType = {
dashboard: 'dashboard',
alert: 'alert',
service: 'service',
saved_view: 'saved_view',
logs_explorer: 'logs_explorer',
traces_explorer: 'traces_explorer',
metrics_explorer: 'metrics_explorer',
} as const;
/** Maps an open_resource action's resourceType to its product module name. */
function targetModuleForResource(resourceType: string): string | null {
switch (resourceType) {
@@ -78,6 +73,8 @@ function targetModuleForResource(resourceType: string): string | null {
return 'alerts';
case ResourceType.service:
return 'apm';
case ResourceType.channel:
return 'channels';
case ResourceType.saved_view:
return 'savedViews';
case ResourceType.logs_explorer:
@@ -140,39 +137,6 @@ function ActionIcon({
}
}
/**
* Resolves an `open_resource` action to an in-app route.
* Resource taxonomy mirrors `MessageContextDTOType`: dashboard, alert,
* saved_view, service, and the *_explorer signals.
*/
function resourceRoute(
resourceType: string,
resourceId: string,
): string | null {
switch (resourceType) {
case ResourceType.dashboard:
return ROUTES.DASHBOARD.replace(':dashboardId', resourceId);
case ResourceType.alert: {
const params = new URLSearchParams({ [QueryParams.ruleId]: resourceId });
return `${ROUTES.EDIT_ALERTS}?${params.toString()}`;
}
case ResourceType.service:
return ROUTES.SERVICE_METRICS.replace(':servicename', resourceId);
case ResourceType.saved_view:
// No detail route — saved views land on the list page.
// Caller may provide signal-aware metadata in future; default to logs.
return ROUTES.LOGS_SAVE_VIEWS;
case ResourceType.logs_explorer:
return ROUTES.LOGS_EXPLORER;
case ResourceType.traces_explorer:
return ROUTES.TRACES_EXPLORER;
case ResourceType.metrics_explorer:
return ROUTES.METRICS_EXPLORER_EXPLORER;
default:
return null;
}
}
/**
* The agent emits `action.query` as the SigNoz REST query-range request body:
*
@@ -484,6 +448,35 @@ export default function ActionsSection({
setResults((prev) => ({ ...prev, [key]: result }));
};
const runOpenSavedView = async (
key: string,
action: MessageActionDTO,
): Promise<void> => {
const resourceId = resolveResourceId(action);
if (!resourceId) {
return;
}
setResult(key, { state: 'loading' });
try {
await openSavedViewByKey(
resourceId,
resolveSavedViewSourceHint(action),
history,
);
void logEvent(AIAssistantEvents.ResourceOpened, {
threadId,
messageId,
targetModule: targetModuleForResource(ResourceType.saved_view),
resourceId,
});
setResult(key, { state: 'success' });
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to open saved view';
setResult(key, { state: 'error', error: message });
}
};
const runRollback = async (
key: string,
action: MessageActionDTO,
@@ -502,6 +495,31 @@ export default function ActionsSection({
}
};
const handleOpenResource = (key: string, action: MessageActionDTO): void => {
if (isSavedViewOpenAction(action)) {
void runOpenSavedView(key, action);
return;
}
const resourceType = resolveOpenResourceType(action);
const resourceId = resolveResourceId(action);
if (!resourceType || !resourceId) {
return;
}
const path = resourceRoute(resourceType, resourceId);
if (!path) {
return;
}
void logEvent(AIAssistantEvents.ResourceOpened, {
threadId,
messageId,
targetModule: targetModuleForResource(resourceType),
resourceId,
});
history.push(path);
};
const handleClick = (key: string, action: MessageActionDTO): void => {
switch (action.kind) {
case MessageActionKindDTO.open_docs: {
@@ -542,21 +560,9 @@ export default function ActionsSection({
}
break;
}
case MessageActionKindDTO.open_resource: {
if (action.resourceType && action.resourceId) {
const path = resourceRoute(action.resourceType, action.resourceId);
if (path) {
void logEvent(AIAssistantEvents.ResourceOpened, {
threadId,
messageId,
targetModule: targetModuleForResource(action.resourceType),
resourceId: action.resourceId,
});
history.push(path);
}
}
case MessageActionKindDTO.open_resource:
handleOpenResource(key, action);
break;
}
case MessageActionKindDTO.undo:
case MessageActionKindDTO.revert:
case MessageActionKindDTO.restore: {

View File

@@ -0,0 +1,283 @@
import {
ApplyFilterSignalDTO,
MessageActionKindDTO,
SavedViewEntityDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { getAllViews } from 'api/saveView/getAllViews';
import { getViewById } from 'api/saveView/getViewById';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
import { AxiosResponse } from 'axios';
import type { History } from 'history';
import {
buildExplorerNavigationUrl,
findSavedViewInLists,
openSavedView,
openSavedViewByKey,
} from '../openSavedView';
import {
entityToDataSource,
isSavedViewOpenAction,
resolveActionEntity,
resolveOpenResourceType,
resolveResourceId,
resolveResourceType,
resolveSavedViewSourceHint,
} from '../resolveOpenResource';
import { resourceRoute, ResourceType } from '../resourceRoute';
jest.mock('api/saveView/getAllViews');
jest.mock('api/saveView/getViewById');
jest.mock(
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
() => ({
mapQueryDataFromApi: jest.fn(() => ({
queryType: 'builder',
builder: {
queryData: [{ id: 'A' }],
queryFormulas: [],
queryTraceOperator: [],
},
})),
}),
);
const mockedGetAllViews = getAllViews as jest.MockedFunction<
typeof getAllViews
>;
const mockedGetViewById = getViewById as jest.MockedFunction<
typeof getViewById
>;
function makeView(id: string, sourcePage: DataSource): ViewProps {
return {
id,
name: `View ${id}`,
category: 'test',
createdAt: '2021-07-07T06:31:00.000Z',
createdBy: 'user',
updatedAt: '2021-07-07T06:33:00.000Z',
updatedBy: 'user',
sourcePage,
tags: [],
extraData: '',
compositeQuery: {
panelType: PANEL_TYPES.LIST,
} as ICompositeMetricQuery,
};
}
function mockViewsResponse(views: ViewProps[]): AxiosResponse<AllViewsProps> {
return {
data: { status: 'success', data: views },
} as AxiosResponse<AllViewsProps>;
}
function mockViewByIdResponse(
view: ViewProps,
): AxiosResponse<{ status: string; data: ViewProps }> {
return {
data: { status: 'success', data: view },
} as AxiosResponse<{ status: string; data: ViewProps }>;
}
describe('resourceRoute', () => {
it('returns null for saved_view so async navigation is used', () => {
expect(resourceRoute(ResourceType.saved_view, 'view-123')).toBeNull();
});
it('routes channels to the edit page', () => {
expect(resourceRoute(ResourceType.channel, 'channel-uuid-1')).toBe(
'/settings/channels/edit/channel-uuid-1',
);
});
});
describe('resolveOpenResource', () => {
it('reads entity from the action envelope', () => {
expect(
resolveActionEntity({
kind: MessageActionKindDTO.open_resource,
label: 'Open view',
entity: SavedViewEntityDTO.traces,
}),
).toBe(SavedViewEntityDTO.traces);
});
it('reads resource id from input.viewKey', () => {
expect(
resolveResourceId({
kind: MessageActionKindDTO.open_resource,
label: 'Open view',
input: { viewKey: 'abc-123' },
}),
).toBe('abc-123');
});
it('maps entity values to explorer data sources', () => {
expect(entityToDataSource('logs')).toBe(DataSource.LOGS);
expect(entityToDataSource('logs_explorer')).toBe(DataSource.LOGS);
expect(entityToDataSource('traces')).toBe(DataSource.TRACES);
});
it('prefers entity over signal for saved-view source hints', () => {
expect(
resolveSavedViewSourceHint({
kind: MessageActionKindDTO.open_resource,
label: 'Open view',
entity: SavedViewEntityDTO.traces,
signal: ApplyFilterSignalDTO.logs,
}),
).toBe(DataSource.TRACES);
});
it('falls back to signal when entity is absent', () => {
expect(
resolveSavedViewSourceHint({
kind: MessageActionKindDTO.open_resource,
label: 'Open view',
signal: ApplyFilterSignalDTO.metrics,
}),
).toBe(DataSource.METRICS);
});
it('normalises saved-view resource types', () => {
expect(
resolveResourceType({
kind: MessageActionKindDTO.open_resource,
label: 'Open view',
resourceType: 'saved-view',
}),
).toBe(ResourceType.saved_view);
});
it('detects open-view actions from label when id is present in input', () => {
expect(
isSavedViewOpenAction({
kind: MessageActionKindDTO.open_resource,
label: 'Open view',
input: { viewId: 'view-1' },
}),
).toBe(true);
});
it('resolves channel type from notification_channel alias', () => {
expect(
resolveResourceType({
kind: MessageActionKindDTO.open_resource,
label: 'Open channel',
resourceType: 'notification_channel',
}),
).toBe(ResourceType.channel);
});
it('infers channel type from Open channel label when resourceId is present', () => {
expect(
resolveOpenResourceType({
kind: MessageActionKindDTO.open_resource,
label: 'Open channel',
resourceId: 'channel-1',
}),
).toBe(ResourceType.channel);
});
});
describe('findSavedViewInLists', () => {
beforeEach(() => {
mockedGetAllViews.mockReset();
});
it('loads only the hinted source when entity is provided', async () => {
const tracesView = makeView('view-traces', DataSource.TRACES);
mockedGetAllViews.mockResolvedValueOnce(mockViewsResponse([tracesView]));
const result = await findSavedViewInLists('view-traces', DataSource.TRACES);
expect(result).toStrictEqual(tracesView);
expect(mockedGetAllViews).toHaveBeenCalledTimes(1);
expect(mockedGetAllViews).toHaveBeenCalledWith(DataSource.TRACES);
});
});
describe('buildExplorerNavigationUrl', () => {
it('encodes composite query and view selectors', () => {
const url = buildExplorerNavigationUrl(
ROUTES.LOGS_EXPLORER,
{ queryType: 'builder' } as never,
{
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
[QueryParams.viewName]: 'My view',
[QueryParams.viewKey]: 'view-1',
},
);
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain(`${QueryParams.compositeQuery}=`);
expect(url).toContain(`${QueryParams.viewKey}=`);
});
});
describe('openSavedView', () => {
it('navigates with history.push and view query params', () => {
const push = jest.fn();
const history = { push } as unknown as History;
const view = makeView('view-logs', DataSource.LOGS);
openSavedView(view, history);
expect(push).toHaveBeenCalledTimes(1);
const pushedUrl = push.mock.calls[0][0] as string;
expect(pushedUrl).toContain(ROUTES.LOGS_EXPLORER);
expect(pushedUrl).toContain(QueryParams.viewKey);
});
});
describe('openSavedViewByKey', () => {
beforeEach(() => {
mockedGetAllViews.mockReset();
mockedGetViewById.mockReset();
});
it('prefers the direct view lookup endpoint', async () => {
const view = makeView('view-logs', DataSource.LOGS);
mockedGetViewById.mockResolvedValueOnce(mockViewByIdResponse(view));
const push = jest.fn();
const history = { push } as unknown as History;
await openSavedViewByKey('view-logs', DataSource.LOGS, history);
expect(mockedGetViewById).toHaveBeenCalledWith('view-logs');
expect(mockedGetAllViews).not.toHaveBeenCalled();
expect(push).toHaveBeenCalled();
});
it('falls back to list probing when direct lookup fails', async () => {
const view = makeView('view-traces', DataSource.TRACES);
mockedGetViewById.mockRejectedValueOnce(new Error('not found'));
mockedGetAllViews.mockResolvedValueOnce(mockViewsResponse([view]));
const push = jest.fn();
const history = { push } as unknown as History;
await openSavedViewByKey('view-traces', DataSource.TRACES, history);
expect(mockedGetAllViews).toHaveBeenCalledWith(DataSource.TRACES);
expect(push).toHaveBeenCalled();
});
it('throws when the saved view does not exist', async () => {
mockedGetViewById.mockRejectedValueOnce(new Error('not found'));
mockedGetAllViews.mockResolvedValue(mockViewsResponse([]));
await expect(
openSavedViewByKey('missing', DataSource.LOGS, {
push: jest.fn(),
} as unknown as History),
).rejects.toThrow('Saved view not found');
});
});

View File

@@ -0,0 +1,117 @@
import { getAllViews } from 'api/saveView/getAllViews';
import { getViewById } from 'api/saveView/getViewById';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import { ViewProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { History } from 'history';
type SavedViewSourceHint = DataSource | 'meter';
const DEFAULT_PROBE_SOURCES: SavedViewSourceHint[] = [
DataSource.LOGS,
DataSource.TRACES,
DataSource.METRICS,
];
export async function findSavedViewInLists(
viewKey: string,
sourceHint?: SavedViewSourceHint | null,
): Promise<ViewProps | null> {
const sources = sourceHint ? [sourceHint] : DEFAULT_PROBE_SOURCES;
for (const source of sources) {
try {
const response = await getAllViews(source);
const match = response.data.data.find((view) => view.id === viewKey);
if (match) {
return match;
}
} catch {
// Probe the next source page when no entity hint is provided.
}
}
return null;
}
async function loadSavedView(
viewKey: string,
sourceHint?: SavedViewSourceHint | null,
): Promise<ViewProps> {
try {
const response = await getViewById(viewKey);
if (response.data?.data) {
return response.data.data;
}
} catch {
// Fall back to list probing when the direct lookup fails.
}
const fromList = await findSavedViewInLists(viewKey, sourceHint);
if (fromList) {
return fromList;
}
throw new Error('Saved view not found');
}
export function explorerRouteForSourcePage(
sourcePage: DataSource | string,
): (typeof SOURCEPAGE_VS_ROUTES)[keyof typeof SOURCEPAGE_VS_ROUTES] | null {
return SOURCEPAGE_VS_ROUTES[sourcePage] ?? null;
}
/**
* Builds an explorer URL the same way `redirectWithQueryBuilderData` does —
* without inheriting stale query params from the current page's `urlQuery`.
*/
export function buildExplorerNavigationUrl(
route: string,
query: Query,
searchParams: Record<string, unknown>,
): string {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
Object.entries(searchParams).forEach(([key, value]) => {
params.set(key, JSON.stringify(value));
});
return `${route}?${params.toString()}`;
}
export function openSavedView(view: ViewProps, history: History): void {
const route = explorerRouteForSourcePage(view.sourcePage);
if (!route) {
throw new Error('Unsupported saved view source');
}
if (!view.compositeQuery) {
throw new Error('Saved view is missing query data');
}
const query = mapQueryDataFromApi(view.compositeQuery);
const url = buildExplorerNavigationUrl(route, query, {
[QueryParams.panelTypes]: view.compositeQuery.panelType as PANEL_TYPES,
[QueryParams.viewName]: view.name,
[QueryParams.viewKey]: view.id,
});
history.push(url);
}
export async function openSavedViewByKey(
viewKey: string,
sourceHint: SavedViewSourceHint | null | undefined,
history: History,
): Promise<void> {
const view = await loadSavedView(viewKey, sourceHint);
openSavedView(view, history);
}
/** @deprecated Use findSavedViewInLists — kept for tests. */
export const findSavedView = findSavedViewInLists;

View File

@@ -0,0 +1,203 @@
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
ApplyFilterSignalDTO,
SavedViewEntityDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { ResourceType } from './resourceRoute';
function readString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
/** Normalises backend resource-type strings to the taxonomy used in the UI. */
export function normalizeResourceType(
resourceType: string | null | undefined,
): string | null {
if (!resourceType) {
return null;
}
const normalized = resourceType.trim().toLowerCase().replace(/-/g, '_');
if (normalized === 'savedview') {
return ResourceType.saved_view;
}
if (
normalized === 'notification_channel' ||
normalized === 'notificationchannel'
) {
return ResourceType.channel;
}
return normalized;
}
/** Reads a resource type from the action envelope or its `input` payload. */
export function resolveResourceType(action: MessageActionDTO): string | null {
const direct = normalizeResourceType(action.resourceType);
if (direct) {
return direct;
}
const input = action.input;
if (!input) {
return null;
}
return (
normalizeResourceType(readString(input.resourceType)) ??
normalizeResourceType(readString(input.type))
);
}
/**
* Resolves the resource type for an `open_resource` action, including label-based
* fallbacks when the backend only sends a display label + id.
*/
export function resolveOpenResourceType(
action: MessageActionDTO,
): string | null {
const fromFields = resolveResourceType(action);
if (fromFields) {
return fromFields;
}
if (/open\s+channel/i.test(action.label) && resolveResourceId(action)) {
return ResourceType.channel;
}
return null;
}
/** Reads a resource id from `resourceId` or common `input` keys. */
export function resolveResourceId(action: MessageActionDTO): string | null {
const direct = readString(action.resourceId);
if (direct) {
return direct;
}
const input = action.input;
if (!input) {
return null;
}
for (const key of [
'resourceId',
'viewId',
'viewKey',
'channelId',
'id',
] as const) {
const value = readString(input[key]);
if (value) {
return value;
}
}
return null;
}
/** Reads `entity` from the action envelope or its `input` payload. */
export function resolveActionEntity(
action: MessageActionDTO,
): SavedViewEntityDTO | null {
if (action.entity) {
return action.entity;
}
const fromInput = readString(action.input?.entity);
if (!fromInput) {
return null;
}
return normalizeToSavedViewEntity(fromInput);
}
function normalizeToSavedViewEntity(value: string): SavedViewEntityDTO | null {
const source = entityToDataSource(value);
switch (source) {
case DataSource.LOGS:
return SavedViewEntityDTO.logs;
case DataSource.TRACES:
return SavedViewEntityDTO.traces;
case DataSource.METRICS:
return SavedViewEntityDTO.metrics;
case 'meter':
return SavedViewEntityDTO.meter;
default:
return null;
}
}
/**
* Maps an action `entity` to an explorer `DataSource` for saved-view lookups.
* Accepts both short (`logs`) and taxonomy (`logs_explorer`) values.
*/
export function entityToDataSource(
entity: SavedViewEntityDTO | string,
): DataSource | 'meter' | null {
const normalized = entity.trim().toLowerCase().replace(/-/g, '_');
switch (normalized) {
case SavedViewEntityDTO.logs:
case ResourceType.logs_explorer:
return DataSource.LOGS;
case SavedViewEntityDTO.traces:
case ResourceType.traces_explorer:
return DataSource.TRACES;
case SavedViewEntityDTO.metrics:
case ResourceType.metrics_explorer:
return DataSource.METRICS;
case SavedViewEntityDTO.meter:
return 'meter';
default:
return null;
}
}
/**
* Picks which explorer source page to search when resolving a saved view.
* Prefers `entity` (open_resource); falls back to `signal` only for legacy payloads.
*/
export function resolveSavedViewSourceHint(
action: MessageActionDTO,
): DataSource | 'meter' | null {
const entity = resolveActionEntity(action);
if (entity) {
const fromEntity = entityToDataSource(entity);
if (fromEntity) {
return fromEntity;
}
}
if (action.signal) {
switch (action.signal) {
case ApplyFilterSignalDTO.logs:
return DataSource.LOGS;
case ApplyFilterSignalDTO.traces:
return DataSource.TRACES;
case ApplyFilterSignalDTO.metrics:
return DataSource.METRICS;
default: {
const _exhaustive: never = action.signal;
return _exhaustive;
}
}
}
return null;
}
export function isSavedViewOpenAction(action: MessageActionDTO): boolean {
if (resolveResourceType(action) === ResourceType.saved_view) {
return true;
}
// Defensive: some agent payloads only set a human label + id in `input`.
return /open\s+view/i.test(action.label) && resolveResourceId(action) !== null;
}

View File

@@ -0,0 +1,50 @@
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
/**
* Resource-type strings the backend uses for `open_resource` and rollback
* actions. Centralized here so route/module lookups stay in sync.
*/
export const ResourceType = {
dashboard: 'dashboard',
alert: 'alert',
service: 'service',
channel: 'channel',
saved_view: 'saved_view',
logs_explorer: 'logs_explorer',
traces_explorer: 'traces_explorer',
metrics_explorer: 'metrics_explorer',
} as const;
/**
* Resolves an `open_resource` action to an in-app route for synchronous
* navigation. Returns `null` for `saved_view` — callers must load the view
* by id and navigate with query-builder state instead.
*/
export function resourceRoute(
resourceType: string,
resourceId: string,
): string | null {
switch (resourceType) {
case ResourceType.dashboard:
return ROUTES.DASHBOARD.replace(':dashboardId', resourceId);
case ResourceType.alert: {
const params = new URLSearchParams({ [QueryParams.ruleId]: resourceId });
return `${ROUTES.EDIT_ALERTS}?${params.toString()}`;
}
case ResourceType.service:
return ROUTES.SERVICE_METRICS.replace(':servicename', resourceId);
case ResourceType.channel:
return ROUTES.CHANNELS_EDIT.replace(':channelId', resourceId);
case ResourceType.saved_view:
return null;
case ResourceType.logs_explorer:
return ROUTES.LOGS_EXPLORER;
case ResourceType.traces_explorer:
return ROUTES.TRACES_EXPLORER;
case ResourceType.metrics_explorer:
return ROUTES.METRICS_EXPLORER_EXPLORER;
default:
return null;
}
}

View File

@@ -101,6 +101,8 @@ function autoContextLabel(ctx: MessageContext): string {
return 'Panel (fullscreen)';
case 'dashboard_list':
return 'Dashboards';
case 'alert_detail':
return 'Current alert';
case 'alert_edit':
return 'Editing alert';
case 'alert_new':

View File

@@ -0,0 +1,26 @@
import type { ComponentProps } from 'react';
type MarkdownExternalLinkProps = ComponentProps<'a'> & {
// react-markdown passes `node` — accept and ignore it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
node?: any;
};
export default function MarkdownExternalLink({
href,
children,
node: _node,
...props
}: MarkdownExternalLinkProps): JSX.Element {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
data-testid="ai-markdown-link"
{...props}
>
{children}
</a>
);
}

View File

@@ -11,6 +11,7 @@ import { Message, MessageBlock } from '../../types';
import ActionsSection from '../ActionsSection';
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
import { RichCodeBlock } from '../blocks';
import MarkdownExternalLink from '../MarkdownExternalLink/MarkdownExternalLink';
import { MessageContext } from '../MessageContext';
import MessageFeedback from '../MessageFeedback';
import UserMessageActions from '../UserMessageActions';
@@ -37,7 +38,11 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
}
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
const MD_COMPONENTS = {
code: RichCodeBlock,
pre: SmartPre,
a: MarkdownExternalLink,
};
type RenderGroup =
| { kind: 'text'; id: string; content: string }

View File

@@ -50,7 +50,7 @@
font-size: 10px;
color: var(--l3-foreground);
white-space: nowrap;
padding-left: 2px;
padding-left: 8px;
border-left: 1px solid var(--l2-border);
}

View File

@@ -13,6 +13,7 @@ import { StreamingEventItem } from '../../types';
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
import ApprovalCard from '../ApprovalCard';
import { RichCodeBlock } from '../blocks';
import MarkdownExternalLink from '../MarkdownExternalLink/MarkdownExternalLink';
import ClarificationForm from '../ClarificationForm';
import messageStyles from '../MessageBubble/MessageBubble.module.scss';
@@ -30,7 +31,11 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
}
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
const MD_COMPONENTS = {
code: RichCodeBlock,
pre: SmartPre,
a: MarkdownExternalLink,
};
type RenderGroup =
| { kind: 'text'; id: string; content: string }

View File

@@ -99,6 +99,30 @@ export function getAutoContexts(
// ── Alerts ────────────────────────────────────────────────────────────────
// Alert detail (overview / per-rule history) — `/alerts/overview?ruleId=…`
// or `/alerts/history?ruleId=…`. Mirrors dashboard_detail: resourceId is the
// rule id and shared metadata carries the URL time range when present.
if (
matchPath(pathname, { path: ROUTES.ALERT_OVERVIEW, exact: true }) ||
matchPath(pathname, { path: ROUTES.ALERT_HISTORY, exact: true })
) {
const ruleId = params.get(QueryParams.ruleId);
if (ruleId) {
return [
{
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: {
page: 'alert_detail',
ruleId,
...sharedMetadata,
},
},
];
}
}
// Alert edit — `/alerts/edit?ruleId=…`.
if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) {
const ruleId = params.get(QueryParams.ruleId);
@@ -108,7 +132,7 @@ export function getAutoContexts(
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: { page: 'alert_edit' },
metadata: { page: 'alert_edit', ruleId },
},
];
}
@@ -125,6 +149,7 @@ export function getAutoContexts(
];
}
// Triggered-alerts index — `/alerts/history` without a rule id.
if (matchPath(pathname, { path: ROUTES.ALERT_HISTORY, exact: true })) {
return [
{

View File

@@ -1,12 +1,10 @@
/* eslint-disable sonarjs/cognitive-complexity */
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import type {
ErrorResponseDTO,
MessageActionDTO,
MessageSummaryDTOBlocksAnyOfItem,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
@@ -37,6 +35,7 @@ import {
MessageBlock,
MessageRole,
} from '../types';
import { resolveAssistantErrorMessage } from '../utils/resolveAssistantErrorMessage';
// ---------------------------------------------------------------------------
// Types used by module-level helpers
@@ -399,6 +398,7 @@ async function runStreamingLoop(
}
throw Object.assign(new Error(event.error.message), {
retryAction: event.retryAction,
code: event.error.code,
});
} else if (event.type === 'conversation' && event.title) {
set((s) => {
@@ -484,36 +484,6 @@ function hasPendingInput(conversationId: string, get: StoreGetter): boolean {
return Boolean(stream?.pendingApproval || stream?.pendingClarification);
}
function parseErrorBody(value: unknown): string | null {
if (typeof value === 'string') {
try {
return parseErrorBody(JSON.parse(value));
} catch {
return null;
}
}
const message = (value as ErrorResponseDTO | undefined)?.error?.message;
return typeof message === 'string' && message.length > 0 ? message : null;
}
/**
* Returns the backend's `error.message` when `err` is a 429 axios response
* (typically from the threads API surface — createThread, sendMessage, approve,
* clarify, regenerate). Returns null for any other error so callers fall
* through to their generic copy.
*/
function rateLimitMessage(err: unknown): string | null {
if (axios.isAxiosError(err) && err.response?.status === 429) {
return parseErrorBody(err.response.data);
}
return null;
}
/**
* Commits an error message and removes the stream entry. When `isRateLimit`
* is true, the committed message is flagged so the feedback/regenerate bar
* is hidden — clicking regenerate would just 429 again.
*/
function finalizeStreamingError(
conversationId: string,
errorContent: string,
@@ -1174,14 +1144,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
return;
}
console.error('[AIAssistant] sendMessage failed:', err);
const rateLimit = rateLimitMessage(err);
finalizeStreamingError(
convId,
rateLimit ??
'Something went wrong while fetching the response. Please try again.',
set,
rateLimit !== null,
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while fetching the response. Please try again.',
);
finalizeStreamingError(convId, message, set, isRateLimit);
}
},
@@ -1214,14 +1181,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
return;
}
console.error('[AIAssistant] approveAction failed:', err);
const rateLimit = rateLimitMessage(err);
finalizeStreamingError(
conversationId,
rateLimit ??
'Something went wrong while processing the approval. Please try again.',
set,
rateLimit !== null,
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while processing the approval. Please try again.',
);
finalizeStreamingError(conversationId, message, set, isRateLimit);
}
},
@@ -1296,14 +1260,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
return;
}
console.error('[AIAssistant] regenerateAssistantMessage failed:', err);
const rateLimit = rateLimitMessage(err);
finalizeStreamingError(
conversationId,
rateLimit ??
'Something went wrong while regenerating the response. Please try again.',
set,
rateLimit !== null,
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while regenerating the response. Please try again.',
);
finalizeStreamingError(conversationId, message, set, isRateLimit);
}
},
@@ -1365,14 +1326,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
return;
}
console.error('[AIAssistant] submitClarification failed:', err);
const rateLimit = rateLimitMessage(err);
finalizeStreamingError(
conversationId,
rateLimit ??
'Something went wrong while processing your answers. Please try again.',
set,
rateLimit !== null,
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while processing your answers. Please try again.',
);
finalizeStreamingError(conversationId, message, set, isRateLimit);
}
},
})),

View File

@@ -0,0 +1,91 @@
import { AxiosError } from 'axios';
import { ErrorCodeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { resolveAssistantErrorMessage } from '../resolveAssistantErrorMessage';
const FALLBACK = 'Something went wrong. Please try again.';
describe('resolveAssistantErrorMessage', () => {
it('returns backend message for a known error code', () => {
const err = new AxiosError('Request failed');
err.response = {
status: 400,
data: {
error: {
code: ErrorCodeDTO.thread_busy,
message: 'This thread is busy. Try again shortly.',
},
},
} as AxiosError['response'];
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: 'This thread is busy. Try again shortly.',
isRateLimit: false,
});
});
it('falls back when error code is not in ErrorCodeDTO', () => {
const err = new AxiosError('Request failed');
err.response = {
status: 400,
data: {
error: {
code: 'future_unknown_code',
message: 'Backend-only message',
},
},
} as AxiosError['response'];
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: FALLBACK,
isRateLimit: false,
});
});
it('marks HTTP 429 responses as rate limited', () => {
const err = new AxiosError('Too many requests');
err.response = {
status: 429,
data: {
error: {
code: ErrorCodeDTO.hourly_message_limit,
message: 'Hourly limit reached.',
},
},
} as AxiosError['response'];
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: 'Hourly limit reached.',
isRateLimit: true,
});
});
it('uses backend message for known SSE rate-limit error codes', () => {
const err = Object.assign(new Error('Daily token limit exceeded.'), {
code: ErrorCodeDTO.daily_token_limit,
});
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: 'Daily token limit exceeded.',
isRateLimit: true,
});
});
it('marks 429 as rate limited even when error code is unknown', () => {
const err = new AxiosError('Too many requests');
err.response = {
status: 429,
data: {
error: {
code: 'future_unknown_code',
message: 'Too many requests',
},
},
} as AxiosError['response'];
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: FALLBACK,
isRateLimit: true,
});
});
});

View File

@@ -0,0 +1,71 @@
import { isAxiosError } from 'axios';
import {
ErrorCodeDTO,
type ErrorBodyDTO,
type ErrorResponseDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
export interface AssistantErrorResolution {
message: string;
isRateLimit: boolean;
}
function isErrorCodeDTO(code: string | undefined): code is ErrorCodeDTO {
return (
code !== undefined && (Object.values(ErrorCodeDTO) as string[]).includes(code)
);
}
const RATE_LIMIT_ERROR_CODES = new Set<ErrorCodeDTO>([
ErrorCodeDTO.rate_limit_override_exceeds_ceiling,
ErrorCodeDTO.thread_message_limit,
ErrorCodeDTO.connection_limit_exceeded,
ErrorCodeDTO.hourly_message_limit,
ErrorCodeDTO.daily_message_limit,
ErrorCodeDTO.daily_token_limit,
ErrorCodeDTO.daily_cost_limit,
ErrorCodeDTO.budget_exceeded,
]);
function isRateLimitError(code: string | undefined, err: unknown): boolean {
if (isAxiosError(err) && err.response?.status === 429) {
return true;
}
return isErrorCodeDTO(code) && RATE_LIMIT_ERROR_CODES.has(code);
}
function getErrorBody(err: unknown): ErrorBodyDTO | null {
if (isAxiosError(err)) {
return (err.response?.data as ErrorResponseDTO | undefined)?.error ?? null;
}
const code = (err as { code?: string } | undefined)?.code;
const message = err instanceof Error ? err.message : undefined;
if (!code || !message) {
return null;
}
return { code: code as ErrorCodeDTO, message };
}
/**
* Uses `error.message` when `error.code` is a known `ErrorCodeDTO`;
* otherwise returns `fallback`.
*/
export function resolveAssistantErrorMessage(
err: unknown,
fallback: string,
): AssistantErrorResolution {
const body = getErrorBody(err);
const isRateLimit = isRateLimitError(body?.code, err);
if (body && isErrorCodeDTO(body.code) && body.message.trim()) {
return {
message: body.message.trim(),
isRateLimit,
};
}
return { message: fallback, isRateLimit: Boolean(isRateLimit) };
}

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

@@ -29,3 +29,7 @@
color: var(--l2-foreground);
}
}
body.ai-assistant-panel-open .create-alert-v2-footer {
right: var(--ai-assistant-panel-width, 380px);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,7 @@
.right-header {
display: flex;
gap: 16px;
align-items: center;
}
}

View File

@@ -15,6 +15,7 @@ import { Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { adjustQueryForV5 } from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
@@ -820,6 +821,11 @@ function NewWidget({
</Flex>
</div>
<div className="right-header">
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
{showSwitchToViewModeButton && (
<Button
color="primary"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,21 @@
import { useMemo } from 'react';
import { Braces, Globe, Table } from '@signozhq/icons';
import { Tabs } from '@signozhq/ui/tabs';
import {
TabItemProps,
TabsContent,
TabsList,
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import GeneralSettings from './General';
import Overview from './Overview';
import { SettingsTabPlaceholder } from './utils';
import VariablesSettings from './Variables';
import { useAppContext } from 'providers/App/App';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { USER_ROLES } from 'types/roles';
import styles from './DashboardSettings.module.scss';
@@ -13,42 +23,70 @@ interface DashboardSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function tabLabel(icon: JSX.Element, text: string): JSX.Element {
return (
<span className={styles.tabLabel}>
{icon}
{text}
</span>
);
enum TabKeys {
OVERVIEW = 'Overview',
VARIABLES = 'Variables',
PUBLISH = 'Publish',
}
const prefixIcons: Record<TabKeys, JSX.Element> = {
[TabKeys.OVERVIEW]: <Table size={14} />,
[TabKeys.VARIABLES]: <Braces size={14} />,
[TabKeys.PUBLISH]: <Globe size={14} />,
};
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
const items = useMemo(
const { user } = useAppContext();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
const items: TabItemProps[] = useMemo(
() => [
{
key: 'general',
label: tabLabel(<Table size={14} />, 'General'),
children: <GeneralSettings dashboard={dashboard} />,
key: TabKeys.OVERVIEW,
label: TabKeys.OVERVIEW,
children: <Overview dashboard={dashboard} />,
},
{
key: 'variables',
label: tabLabel(<Braces size={14} />, 'Variables'),
children: (
<SettingsTabPlaceholder message="V2 dashboard variables coming next." />
),
},
{
key: 'public-dashboard',
label: tabLabel(<Globe size={14} />, 'Publish'),
children: (
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
),
key: TabKeys.VARIABLES,
label: TabKeys.VARIABLES,
children: <VariablesSettings dashboard={dashboard} />,
},
...(enablePublicDashboard
? [
{
key: TabKeys.PUBLISH,
label: TabKeys.PUBLISH,
children: (
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
),
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,57 +0,0 @@
.dashboardBreadcrumbs {
width: 100%;
height: 48px;
display: flex;
gap: 6px;
align-items: center;
max-width: 80%;
padding-left: 8px;
.linkToPreviousPage {
// Collapse the design-system Button's fixed-height/padding box so it hugs
// the label like inline text (the breadcrumb is text, not a chunky button).
--button-height: auto;
--button-padding: 0;
--button-gap: 4px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
}
.currentPage {
display: flex;
align-items: center;
gap: 4px;
padding: 0px 2px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
height: 20px;
max-width: calc(100% - 120px);
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.currentPage:hover {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-300);
}
.dashboardIconImage {
height: 14px;
width: 14px;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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