Compare commits

..

15 Commits

Author SHA1 Message Date
Abhi Kumar
9a468e4c62 feat(dashboards-v2): thresholds section editor with per-variant rows 2026-06-18 05:27:03 +05:30
Abhi Kumar
93b1827911 feat(dashboards-v2): chart panel section editors 2026-06-18 05:27:03 +05:30
Abhi Kumar
ee687526b6 feat(dashboards-v2): config pane shell with reusable form controls 2026-06-18 05:27:03 +05:30
Abhi Kumar
ddecb0affb feat(dashboards-v2): optional footer slot below the query builder 2026-06-18 05:26:10 +05:30
Abhi Kumar
effe3220fd fix(dashboards-v2): persist raw/list panels as a bare BuilderQuery 2026-06-18 05:26:10 +05:30
Abhi Kumar
3691cacefa fix(dashboards-v2): make the editor dirty-check immune to query re-serialization 2026-06-18 05:26:10 +05:30
Abhi Kumar
868e21ebb0 feat(dashboards-v2): panel editor route with live preview and query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 02:06:08 +05:30
Abhi Kumar
7f75ccbe78 feat(dashboards-v2): role and kind-gated panel actions with header chrome 2026-06-18 02:03:00 +05:30
Abhi Kumar
d9228de643 refactor(dashboards-v2): per-kind panel definitions and registry 2026-06-18 02:03:00 +05:30
Abhi Kumar
bf5fc0e013 feat(dashboards-v2): panel query hook with pagination and time window 2026-06-18 02:03:00 +05:30
Abhi Kumar
119ae5d23b feat(dashboards-v2): pure-V5 perses query adapter and request builder 2026-06-18 02:03:00 +05:30
Nikhil Soni
b50933d622 chore(trace-details): remove flamegraph v2 API (#11629)
* chore: remove flamegraph v2 since we've moved to v4 now

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

* chore: remove orphaned helpers after v2 waterfall and flamegraph removal

Drop GetSpansForTrace, cacheForTraceDetail, fluxIntervalForTraceDetail from
clickhouseReader — all were only used by the deleted v2 handlers. Also remove
the orphaned model.Span and model.Event types, and clean up the
cacheForTraceDetail initialization from server.go.

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

* fix: update ee/query-service NewReader call after v2 cleanup

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

* chore: format whitespacing

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 10:24:24 +00:00
Abhi kumar
8bf649a3a5 feat(dashboards-v2): panel rendering system (pure-V5 data path, renderers, panel chrome) (#11639)
* feat(dashboards-v2): panel type system & shared chart utilities

* feat(dashboards-v2): usePanelQuery data-fetching hook

* feat(dashboards-v2): TimeSeriesPanel renderer

* feat(dashboards-v2): BarChartPanel renderer

* feat(dashboards-v2): HistogramPanel renderer

* feat(dashboards-v2): PieChartPanel renderer

* feat(dashboards-v2): panel plugin registry

* feat(dashboards-v2): panel status popover (error/warning surfacing)

* feat(dashboards-v2): panel chrome - header, body, interactions & grid wiring

* chore(dashboards-v2): dashboards-list-v2 query-param sort/order adjustments

* feat(dashboards-v2): number panel kind + registry entry

* fix(dashboards-v2): cap bottom legend height at 30% of the panel

The bottom-legend reservation was capped only at min(2 rows, 80px) with
no relation to the container height, despite the helper's doc promising a
%-of-container cap. On short grid panels the legend kept its full slice
and the chart — the pie donut especially — collapsed to a sliver.

- calculateChartDimensions now also caps the bottom legend at 30% of the
  container height (mirroring the RIGHT-legend width cap).
- Drop the legend grid row-gap so the capped height isn't overrun.
- Add the previously-missing tests for calculateChartDimensions,
  including the short-container cap.

echo "--- commit 1 done ---" && git log --oneline -1
echo "--- remaining ---" && git status --short

* fix(dashboards-v2): prevent pie donut and labels from clipping

The donut used a flat radius = 0.35 * min(w,h) with leader labels anchored
at 1.3 * radius, leaving only ~0.045 * size between the label anchor and
the SVG edge — so on small panels the top/bottom labels (and a stray
container padding under border-box) clipped.

- getDonutGeometry now solves the radius back from the box half-extent
  minus a fixed label allowance, so the label anchor always lands a
  constant 22px inside the edge regardless of panel size — extracted as a
  pure, tested helper with a shared label-ratio constant (no drift with
  getArcGeometry) and a DonutGeometry type.
- Align the pie legend wrapper padding with ChartLayout's.
- Tests for getDonutGeometry (anchor stays in-box, derived radii, smaller
  axis wins, no negative radius).

echo "--- commit 2 done ---" && git log --oneline -3
echo "--- remaining ---" && git status --short

* refactor(dashboards-v2): address PR review feedback

Tightening + structure changes from review of #11639:

- types: make rendererProps `panel`/`data` required and `syncMode`
  non-optional (None is the off-state); `Panel.panel` required with
  orphan layout items guarded in SectionGrid; key the panel interaction
  map by PanelKind; require `requestPayload` in useGetQueryRangeV5.
- panel chrome: split the unknown-kind path into UnsupportedPanelBody so
  PanelBody only runs with a resolved renderer; rename chartBody ->
  chartContainer; move PanelHeader/PanelBody into their own folders with
  dedicated scss (Panel.module.scss trimmed to .panel).
- panel status: normalize query failures via convertToApiError (the
  generated client rejects with a raw AxiosError, so the old isAPIError
  guard never matched and dropped the backend code/message); drop an
  unnecessary useMemo; docs link -> Typography.Link.
- registry: PascalCase aliases; Panels/index.ts -> registry.ts.
- utils: extract selectionPreferences out of baseConfigBuilder; split
  chartAppearanceMappings into chartAppearance/{enumMaps,resolvers};
  move each kind's buildConfig under utils/.
- renderers: render NoData on an empty result set.
- dashboards-list: drop redundant sort/order casts.

echo "=== result ==="; git log --oneline -3; echo "--- working tree ---"; git status --short

* refactor(dashboards-v2): use @signozhq/ui tooltip for panel status

Replace the antd Popover in the panel header status indicator with
@signozhq/ui TooltipSimple. Relies on the global TooltipProvider mounted
in AppLayout; the PanelHeader test wraps renders in TooltipProvider since
the radix tooltip needs that ancestor in isolation.

* refactor(dashboards-v2): derive PanelKind from generated contract

Type panel kinds off DashboardtypesPanelPluginKindDTO instead of a
hand-written union, keeping the generated API contract as the single
source of truth. getPanelDefinition and PANEL_KIND_TO_PANEL_TYPE now take
PanelKind directly, dropping the `as PanelKind` casts at their call sites.

Renderers drop the defensive optional chaining / `?? {}` fallbacks the
render boundary already guarantees, and panelDef is renamed to
panelDefinition for clarity.

* chore: pr reviews fixes
2026-06-17 09:54:04 +00:00
Naman Verma
9150c2ac87 feat: introduce dashboard views for list dashboards page (#11465)
* feat: functional unique index in sql schema

* fix: only ascii in regex

* fix: use sync tags method in update

* fix: use sync tags method in update

* fix: correct the method name being called

* chore: rename create method to createOrGet

* chore: use tagtypestest package for mock store

* chore: combine functional unique index with unique index

* chore: move tag resolution to module

* test: add unit tests for new idx type

* feat: delete dashboard v2 API

* chore: comment out tags unique index for now

* chore: add a todo comment

* chore: comment out unique index test

* feat: add created at to tag relations

* chore: comment out unique index test

* chore: bump migration number

* chore: remove uploaded grafana flag from metadata

* Merge branch 'main' into nv/v2-dashboard-create

* chore: revert idx generation to resolve conflicts

* fix: use store.RunInTx instead of taking in sqlstore

* fix: use binding package to get request

* chore: move NewDashboardV2 to NewDashboardV2WithoutTags

* chore: rename module to m

* fix: add ctx needed in sqlstore

* fix: remove sqlstore passage in ee pkg

* chore: change dashboardData to dashboardSpec

* feat: follow the metadata+spec key structure

* feat: follow the metadata+spec key structure in open api spec

* feat: v2 dashboard GET API (#11136)

* feat: v2 dashboard GET API

* Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get

* chore: update api specs

* fix: remove soft delete references

* chore: embed StorableDashboard into joinedRow in store method

* fix: fix build error

* chore: revert all frontend changes

* fix: remove public dashboard from get v2 call

* chore: revert all frontend changes

* fix: fix build errors post merge conflict resolution

* feat: lock, unlock, create public, update public v2 dashboard APIs (#11167)

* feat: lock, unlock, create public, update public v2 dashboard APIs

* chore: update api specs

* fix: use new pattern of checking for admin permission

* fix: remove soft delete reference

* chore: revert all frontend changes

* fix: fix build errors and remove v2 create/update public apis

* chore: use v1 methods wherever possible

* fix: use update v2 store method

* chore: update frontend schema

* chore: update frontend schema

* chore: generate api specs

* chore: generate api specs

* feat: patch dashboard api (#11182)

* feat: lock, unlock, create public, update public v2 dashboard APIs

* feat: delete dashboard v2 API and hard delete cron job

* feat: patch dashboard api

* chore: update api specs

* chore: update api specs

* chore: update api specs

* chore: remove delete related work

* fix: add examples of structs for value param in param description

* test: unit test fixes

* fix: use new pattern of checking for admin permission

* fix: remove soft delete reference

* test: key value tags in test

* fix: build error in patch module method

* fix: build error in Apply method

* fix: use sync tags method in update

* fix: fix build errors

* fix: fix all patch application tests

* chore: add more mapper methods

* fix: fix build errors

* chore: generate api specs

* fix: update migration numbering

* fix: add missing request struct in list api

* fix: remove hasMore from list response

* chore: bump migration number

* fix: send total count in response + bug fixes

* fix: add source for v2 dashboards

* chore: incorporate source

* chore: incorporate source in api spec

* chore: incorporate source

* fix: remove system dashboards from list v2 response

* fix: add some required fields

* feat: add immutable name in dashboard v2

* feat: add immutable name in dashboard v2

* feat: add immutable name in dashboard v2 api specs

* fix: remove unused param in constructor

* fix: improve api descriptions

* fix: remove unneeded comment

* chore: increase MaxTagsPerDashboard to 10

* fix: set display name in unmarshal json

* chore: remove integration test for now (will add along with list api)

* feat: add validation on dashboard name

* test: fix build errors and tests based on name related changes

* chore: bump migration number

* chore: generate api specs

* fix: fix tests based on name related changes

* fix: dont include full data in list response

* fix: add quotes around tag relation kind

* chore: bump migration number

* fix: fix post merge build and spec errors

* fix: correct convertor method name

* test: add unit tests for type conversions

* chore: remove enum def of threshold comparison operator

* feat: add flag to generate unique name in backend

* chore: generate api specs

* chore: make tags required in postable

* fix: build error fix

* chore: bump migration number

* feat: introduce dashboard views for list dashboards page

* fix: fix build error in test after merge conflict

* fix: remove unused store method

* fix: remove unused module methods

* fix: use v1 store update method

* fix: change data to spec in api param description

* chore: add back accidentally removed tests

* chore: update api spec

* chore: bump migration number

* feat: delete dashboard v2 API (#11299)

* feat: delete dashboard v2 API

* fix: fix post merge build and spec errors

* fix: address review comments

* chore: generate frontend api spec

* fix: add missing name fetch in listv2 store method

* fix: change title to name in api description

* fix: add all error codes for new apis

* test: change data to spec in unit tests

* fix: remove join to public dashboard table in list call

* fix: use valuer string for list order and sort

* test: integration test and fixes found through it

* chore: use same jsonpatch package as done in zeus

* chore: remove JSONPatchDocument and use patchable everywhere

* fix: make remove idempotent in patch

* chore: separate file for patch types

* chore: better error passage

* fix: remove extra decodePatch calls

* fix: fix post merge build and schema errors

* fix: remove omitempty

* chore: bump migration number

* fix: remove user auditable

* chore: dont pass email to create and update methods

* chore: dont pass email to create and update methods

* chore: regenerate api specs

* chore: rearrange

* fix: return better err

* chore: add all err codes in api spec

* chore: rearrange

* fix: use must new org id

* fix: proper error passage

* chore: rename updateable to updatable

* fix: use must new org id

* fix: use must new org id

* feat: include list of all dashboard tags in list api response

* fix: remove wrong api description msg

* fix: use must method for user id as well

* chore: add nolint comment

* fix: add missing image field in list response

* chore: regenerate api specs

* chore: regenerate api specs

* fix: make GettableTag a defined type instead of an alias

* fix: dont allow system dashboards to be deleted

* fix: remove public filter from visitor

* chore: use go sqlbuilder

* fix: use ESCAPE literal in contains and like operators

* fix: use correct perses package in list v2 file

* feat: change pinned dashboard table to user dashboard preference table

* fix: delete preferences on dashboard delete

* test: add integration test for pinning

* fix: wrap naked errors

* fix: integration dashboards should not be deletable either

* fix: remove org column in preferences and add foreign key to users table

* chore: add fk from prefs to dashbaord table

* chore: remove outer parenthesis removal function

* test: add unit test to ensure that all reserved keys have handlers

* fix: proper url for pin apis

* fix: delete preferences on user deletion

* test: address integration test comments

* test: change limit

* fix: revert the check in can delete

* fix: remove unit test from ee package

* fix: move list filter to impl to avoid db impl logic in types

* chore: code movement

* feat: add a pin free list dashboards api

* fix: update api specs

* fix: use request query in api defs for list apis

* chore: explicitly mark request as nil in list apis

* fix: remove extra noop assignment

* chore: remove separate gettable view typedef

* chore: rearrange structs

* fix: cover errors, fix column type, add tests

* test: add unit tests for dashboard view validations

* fix: move view create/edit/delete to edit access

* fix: move view create/edit/delete to edit access

* fix: change url and fix api descriptions

* chore: add query max length check for views

* fix: wrong spelling for UpdatableDashboardView

* fix: extract common validation logic between list api and saved views

* test: add integration tests for dashboard views

* test: add similar tests for dashboards as well

* fix: dont trim name in views

* fix: add integration test for trailing whitespace rejection

* fix: return name required err for name with only spaces

* test: fix python formatting

* fix: generate api specs

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-17 08:33:07 +00:00
Naman Verma
78df13dabd perf: reuse label maps and index series by variable in formulas (#11529)
* perf: reuse label maps and index series by variable in formulas

* perf: switch to simple worker pool

* perf: use a pointer free backing array for result values

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-17 05:34:46 +00:00
236 changed files with 16859 additions and 1463 deletions

View File

@@ -2591,6 +2591,41 @@ components:
- panels
- layouts
type: object
DashboardtypesDashboardView:
properties:
createdAt:
format: date-time
type: string
data:
$ref: '#/components/schemas/DashboardtypesDashboardViewData'
id:
type: string
name:
type: string
orgId:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- data
- orgId
type: object
DashboardtypesDashboardViewData:
properties:
order:
$ref: '#/components/schemas/DashboardtypesListOrder'
query:
type: string
sort:
$ref: '#/components/schemas/DashboardtypesListSort'
version:
type: string
required:
- version
type: object
DashboardtypesDatasourcePlugin:
discriminator:
mapping:
@@ -2875,6 +2910,15 @@ components:
- total
- tags
type: object
DashboardtypesListableDashboardView:
properties:
views:
items:
$ref: '#/components/schemas/DashboardtypesDashboardView'
type: array
required:
- views
type: object
DashboardtypesListedDashboardForUserV2:
properties:
createdAt:
@@ -3179,6 +3223,16 @@ components:
- tags
- spec
type: object
DashboardtypesPostableDashboardView:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardViewData'
name:
type: string
required:
- name
- data
type: object
DashboardtypesPostablePublicDashboard:
properties:
defaultTimeRange:
@@ -13328,6 +13382,231 @@ paths:
summary: Update user preference
tags:
- preferences
/api/v2/dashboard_views:
get:
deprecated: false
description: Returns every saved view in the calling user's org. Saved views
are shared org-wide.
operationId: ListDashboardViews
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesListableDashboardView'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List dashboard saved views
tags:
- dashboard
post:
deprecated: false
description: Persists the calling user's dashboard listing state (query, sort,
order) as a named, reusable view shared across the org.
operationId: CreateDashboardView
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPostableDashboardView'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardView'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Create dashboard saved view
tags:
- dashboard
/api/v2/dashboard_views/{id}:
delete:
deprecated: false
description: Removes a saved view. Saved views are shared org-wide. Deleting
a non-existent view returns 404.
operationId: DeleteDashboardView
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Delete dashboard saved view
tags:
- dashboard
put:
deprecated: false
description: Replaces a saved view's name and data. Saved views are shared org-wide.
operationId: UpdateDashboardView
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPostableDashboardView'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardView'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Update dashboard saved view
tags:
- dashboard
/api/v2/dashboards:
get:
deprecated: false

View File

@@ -266,6 +266,22 @@ func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, orgID, userID)
}
func (module *module) CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error) {
return module.pkgDashboardModule.CreateView(ctx, orgID, postable)
}
func (module *module) ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error) {
return module.pkgDashboardModule.ListViews(ctx, orgID)
}
func (module *module) UpdateView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updateable dashboardtypes.UpdatableDashboardView) (*dashboardtypes.DashboardView, error) {
return module.pkgDashboardModule.UpdateView(ctx, orgID, id, updateable)
}
func (module *module) DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.DeleteView(ctx, orgID, id)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -10,7 +10,6 @@ import (
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/propagation"
"github.com/SigNoz/signoz/pkg/cache/memorycache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/gorilla/handlers"
@@ -20,7 +19,6 @@ import (
"github.com/SigNoz/signoz/ee/query-service/app/api"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/web"
@@ -59,25 +57,12 @@ type Server struct {
// NewServer creates and initializes Server
func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
cacheForTraceDetail, err := memorycache.New(context.TODO(), signoz.Instrumentation.ToProviderSettings(), cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 10 * 10000,
MaxCost: 1 << 27, // 128 MB
},
})
if err != nil {
return nil, err
}
reader := clickhouseReader.NewReader(
signoz.Instrumentation.Logger(),
signoz.SQLStore,
signoz.TelemetryStore,
signoz.Prometheus,
signoz.TelemetryStore.Cluster(),
config.Querier.FluxInterval,
cacheForTraceDetail,
signoz.Cache,
nil,
)

View File

@@ -1,101 +0,0 @@
package postgressqlschema
import (
"context"
"os"
"testing"
"time"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/stretchr/testify/require"
)
// devenvPostgresDSN is the DSN for the postgres started by `make devenv-postgres`.
// Override with TEST_POSTGRES_DSN to point at a different instance.
const devenvPostgresDSN = "postgres://postgres:password@localhost:5432/signoz?sslmode=disable"
// TestSignozDBTagUniqueIndex inspects the real postgres database the enterprise
// server migrates and verifies the functional unique index added by migration
// 094 on the "tag" table.
//
// - "MigrationCreatedIndex" is the ground-truth check: it reads the index
// definition straight out of pg_indexes and confirms the functional unique
// index physically exists. This proves the migration ran and postgres
// accepted it.
// - "GetIndicesRoundTrip" exercises the engine's GetIndices read-back path and
// checks it reconstructs the same index. This is the part your colleague
// asked about.
//
// It mirrors the sqlite signoz.db test, but talks to the devenv postgres
// container instead of a local file. The test skips if postgres is unreachable
// (run `make devenv-postgres` and the enterprise server first).
func TestSignozDBTagUniqueIndex(t *testing.T) {
dsn := os.Getenv("TEST_POSTGRES_DSN")
if dsn == "" {
dsn = devenvPostgresDSN
}
ctx := context.Background()
cfg := sqlstore.Config{
Provider: "postgres",
Postgres: sqlstore.PostgresConfig{DSN: dsn},
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10, MaxConnLifetime: time.Minute},
}
providerSettings := instrumentationtest.New().ToProviderSettings()
store, err := postgressqlstore.New(ctx, providerSettings, cfg)
if err != nil {
t.Skipf("postgres unreachable at %s (run `make devenv-postgres` and the enterprise server): %v", dsn, err)
}
pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := store.SQLDB().PingContext(pingCtx); err != nil {
t.Skipf("postgres unreachable at %s (run `make devenv-postgres` and the enterprise server): %v", dsn, err)
}
t.Logf("using postgres at %s", dsn)
schema, err := New(ctx, providerSettings, sqlschema.Config{}, store)
require.NoError(t, err)
expected := &sqlschema.UniqueIndex{
TableName: "tag",
ColumnNames: []sqlschema.ColumnName{"org_id", "kind", "key", "value"},
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
}
t.Run("MigrationCreatedIndex", func(t *testing.T) {
var def string
err := store.
BunDB().
NewRaw("SELECT indexdef FROM pg_indexes WHERE tablename = 'tag' AND indexname = ?", expected.Name()).
Scan(ctx, &def)
require.NoError(t, err, "expected unique index %q to exist in postgres", expected.Name())
t.Logf("stored indexdef: %s", def)
require.Contains(t, def, "UNIQUE")
// postgres normalizes function names to lowercase.
require.Contains(t, def, "lower(key)")
require.Contains(t, def, "lower(value)")
})
t.Run("GetIndicesRoundTrip", func(t *testing.T) {
indices, err := schema.GetIndices(ctx, "tag")
require.NoError(t, err)
t.Logf("GetIndices returned %d indices", len(indices))
var got sqlschema.Index
for _, idx := range indices {
t.Logf(" name=%q type=%s columns=%v create=%s", idx.Name(), idx.Type(), idx.Columns(), string(idx.ToCreateSQL(schema.Formatter())))
if idx.Name() == expected.Name() {
got = idx
}
}
require.NotNil(t, got, "GetIndices did not return the functional unique index %q", expected.Name())
require.True(t, expected.Equals(got), "round-tripped index should equal the original definition")
})
}

View File

@@ -29,6 +29,18 @@ if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function (): void {};
}
// jsdom doesn't implement the Pointer Capture API, which Radix UI primitives
// (e.g. @signozhq/ui Select) call when opening. Stub them so those components
// can be exercised in tests.
if (!HTMLElement.prototype.hasPointerCapture) {
HTMLElement.prototype.hasPointerCapture = function (): boolean {
return false;
};
}
if (!HTMLElement.prototype.releasePointerCapture) {
HTMLElement.prototype.releasePointerCapture = function (): void {};
}
if (typeof window.IntersectionObserver === 'undefined') {
class IntersectionObserverMock {
observe(): void {}

View File

@@ -122,6 +122,13 @@ export const DashboardWidget = Loadable(
import(/* webpackChunkName: "DashboardWidgetPage" */ 'pages/DashboardWidget'),
);
export const DashboardPanelEditorPage = Loadable(
() =>
import(
/* webpackChunkName: "DashboardPanelEditorPage" */ 'pages/DashboardPageV2/PanelEditorPage/PanelEditorPage'
),
);
export const EditRulesPage = Loadable(
() => import(/* webpackChunkName: "Alerts Edit Page" */ 'pages/EditRules'),
);

View File

@@ -11,6 +11,7 @@ import {
CreateAlertChannelAlerts,
CreateNewAlerts,
DashboardPage,
DashboardPanelEditorPage,
DashboardsListPage,
DashboardWidget,
EditRulesPage,
@@ -196,6 +197,13 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'DASHBOARD_WIDGET',
},
{
path: ROUTES.DASHBOARD_PANEL_EDITOR,
exact: true,
component: DashboardPanelEditorPage,
isPrivate: true,
key: 'DASHBOARD_PANEL_EDITOR',
},
{
path: ROUTES.EDIT_ALERTS,
exact: true,

View File

@@ -21,14 +21,17 @@ import type {
CloneDashboardV2201,
CloneDashboardV2PathParameters,
CreateDashboardV2201,
CreateDashboardView201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesPatchableDashboardV2DTO,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostableDashboardViewDTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatableDashboardV2DTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeleteDashboardV2PathParameters,
DeleteDashboardViewPathParameters,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
GetDashboardV2PathParameters,
@@ -38,6 +41,7 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
ListDashboardViews200,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListDashboardsV2200,
@@ -51,6 +55,8 @@ import type {
UnpinDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdateDashboardView200,
UpdateDashboardViewPathParameters,
UpdatePublicDashboardPathParameters,
} from '../sigNoz.schemas';
@@ -650,6 +656,354 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
* @summary List dashboard saved views
*/
export const listDashboardViews = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListDashboardViews200>({
url: `/api/v2/dashboard_views`,
method: 'GET',
signal,
});
};
export const getListDashboardViewsQueryKey = () => {
return [`/api/v2/dashboard_views`] as const;
};
export const getListDashboardViewsQueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardViews>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListDashboardViewsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listDashboardViews>>
> = ({ signal }) => listDashboardViews(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardViews>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardViewsQueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardViews>>
>;
export type ListDashboardViewsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboard saved views
*/
export function useListDashboardViews<
TData = Awaited<ReturnType<typeof listDashboardViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardViews>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardViewsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboard saved views
*/
export const invalidateListDashboardViews = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardViewsQueryKey() },
options,
);
return queryClient;
};
/**
* Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.
* @summary Create dashboard saved view
*/
export const createDashboardView = (
dashboardtypesPostableDashboardViewDTO?: BodyType<DashboardtypesPostableDashboardViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateDashboardView201>({
url: `/api/v2/dashboard_views`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardViewDTO,
signal,
});
};
export const getCreateDashboardViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
> => {
const mutationKey = ['createDashboardView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createDashboardView>>,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> }
> = (props) => {
const { data } = props ?? {};
return createDashboardView(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateDashboardViewMutationResult = NonNullable<
Awaited<ReturnType<typeof createDashboardView>>
>;
export type CreateDashboardViewMutationBody =
| BodyType<DashboardtypesPostableDashboardViewDTO>
| undefined;
export type CreateDashboardViewMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create dashboard saved view
*/
export const useCreateDashboardView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
> => {
return useMutation(getCreateDashboardViewMutationOptions(options));
};
/**
* Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.
* @summary Delete dashboard saved view
*/
export const deleteDashboardView = (
{ id }: DeleteDashboardViewPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/dashboard_views/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteDashboardViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
> => {
const mutationKey = ['deleteDashboardView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteDashboardView>>,
{ pathParams: DeleteDashboardViewPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteDashboardView(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteDashboardViewMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteDashboardView>>
>;
export type DeleteDashboardViewMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete dashboard saved view
*/
export const useDeleteDashboardView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
> => {
return useMutation(getDeleteDashboardViewMutationOptions(options));
};
/**
* Replaces a saved view's name and data. Saved views are shared org-wide.
* @summary Update dashboard saved view
*/
export const updateDashboardView = (
{ id }: UpdateDashboardViewPathParameters,
dashboardtypesPostableDashboardViewDTO?: BodyType<DashboardtypesPostableDashboardViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateDashboardView200>({
url: `/api/v2/dashboard_views/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardViewDTO,
signal,
});
};
export const getUpdateDashboardViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
> => {
const mutationKey = ['updateDashboardView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateDashboardView>>,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateDashboardView(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateDashboardViewMutationResult = NonNullable<
Awaited<ReturnType<typeof updateDashboardView>>
>;
export type UpdateDashboardViewMutationBody =
| BodyType<DashboardtypesPostableDashboardViewDTO>
| undefined;
export type UpdateDashboardViewMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update dashboard saved view
*/
export const useUpdateDashboardView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
> => {
return useMutation(getUpdateDashboardViewMutationOptions(options));
};
/**
* Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
* @summary List dashboards (v2)

View File

@@ -4633,6 +4633,54 @@ export interface DashboardtypesDashboardSpecDTO {
variables: DashboardtypesVariableDTO[];
}
export enum DashboardtypesListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum DashboardtypesListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
}
export interface DashboardtypesDashboardViewDataDTO {
order?: DashboardtypesListOrderDTO;
/**
* @type string
*/
query?: string;
sort?: DashboardtypesListSortDTO;
/**
* @type string
*/
version: string;
}
export interface DashboardtypesDashboardViewDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
data: DashboardtypesDashboardViewDataDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export enum DashboardtypesDatasourcePluginKindDTO {
'signoz/Datasource' = 'signoz/Datasource',
}
@@ -4744,15 +4792,6 @@ export interface DashboardtypesJSONPatchOperationDTO {
value?: unknown;
}
export enum DashboardtypesListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum DashboardtypesListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
}
export interface DashboardtypesListedDashboardV2SpecDTO {
display?: DashboardtypesDisplayDTO;
}
@@ -4895,6 +4934,13 @@ export interface DashboardtypesListableDashboardV2DTO {
total: number;
}
export interface DashboardtypesListableDashboardViewDTO {
/**
* @type array
*/
views: DashboardtypesDashboardViewDTO[];
}
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
@@ -4946,6 +4992,14 @@ export interface DashboardtypesPostableDashboardV2DTO {
tags: TagtypesPostableTagDTO[] | null;
}
export interface DashboardtypesPostableDashboardViewDTO {
data: DashboardtypesDashboardViewDataDTO;
/**
* @type string
*/
name: string;
}
export interface DashboardtypesPostablePublicDashboardDTO {
/**
* @type string
@@ -9837,6 +9891,36 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListDashboardViews200 = {
data: DashboardtypesListableDashboardViewDTO;
/**
* @type string
*/
status: string;
};
export type CreateDashboardView201 = {
data: DashboardtypesDashboardViewDTO;
/**
* @type string
*/
status: string;
};
export type DeleteDashboardViewPathParameters = {
id: string;
};
export type UpdateDashboardViewPathParameters = {
id: string;
};
export type UpdateDashboardView200 = {
data: DashboardtypesDashboardViewDTO;
/**
* @type string
*/
status: string;
};
export type ListDashboardsV2Params = {
/**
* @type string

View File

@@ -24,6 +24,7 @@ const ROUTES = {
ALL_DASHBOARD: '/dashboard',
DASHBOARD: '/dashboard/:dashboardId',
DASHBOARD_WIDGET: '/dashboard/:dashboardId/:widgetId',
DASHBOARD_PANEL_EDITOR: '/dashboard/:dashboardId/panel/:panelId',
EDIT_ALERTS: '/alerts/edit',
LIST_ALL_ALERT: '/alerts',
ALERTS_NEW: '/alerts/new',

View File

@@ -408,6 +408,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const isPublicDashboard = pathname.startsWith('/public/dashboard/');
const isAIAssistantPage = pathname.startsWith('/ai-assistant/');
// The V2 panel editor is a chromeless full-page route (no side nav / top nav),
// like the onboarding and public-dashboard screens.
const isPanelEditorV2 = routeKey === 'DASHBOARD_PANEL_EDITOR';
const renderFullScreen =
pathname === ROUTES.GET_STARTED ||
@@ -418,7 +421,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
isPublicDashboard;
isPublicDashboard ||
isPanelEditorV2;
const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false);

View File

@@ -63,5 +63,6 @@
flex: 0 0 auto;
min-height: 0;
min-width: 0;
padding: 8px;
padding-left: 12px;
padding-bottom: 12px;
}

View File

@@ -16,7 +16,7 @@ import PieArc from './PieArc';
import PieCenterLabel from './PieCenterLabel';
import styles from './Pie.module.scss';
import { PieTooltipData } from './types';
import { getFillColor } from './utils';
import { getDonutGeometry, getFillColor } from './utils';
/**
* Donut chart rendered with @visx. Splits its area into chart + legend with the
@@ -78,16 +78,12 @@ export default function Pie({
[containerWidth, containerHeight, position, data],
);
// Donut geometry derived from the allocated chart box.
const { size, radius, innerRadius } = useMemo(() => {
const nextSize = Math.min(width, height);
const nextRadius = nextSize * 0.35;
return {
size: nextSize,
radius: nextRadius,
innerRadius: nextRadius * 0.6,
};
}, [width, height]);
// Donut geometry derived from the allocated chart box, sized to leave room
// for the external leader labels (see getDonutGeometry).
const { size, radius, innerRadius } = useMemo(
() => getDonutGeometry(width, height),
[width, height],
);
const totalValue = useMemo(
() => visibleData.reduce((sum, slice) => sum + slice.value, 0),

View File

@@ -1,11 +1,40 @@
import {
getArcGeometry,
getDonutGeometry,
getFillColor,
getScaledFontSize,
lightenColor,
} from '../utils';
describe('Pie utils', () => {
describe('getDonutGeometry', () => {
it('keeps the label anchor inside the box (reserves room for leader labels)', () => {
const { radius } = getDonutGeometry(400, 300);
const half = Math.min(400, 300) / 2; // 150
// The label anchor sits at radius * 1.3 and must stay within the box
// half-extent so labels are not clipped.
expect(radius * 1.3).toBeLessThanOrEqual(half);
// And it should use the available room (anchor = half - 22 allowance).
expect(radius * 1.3).toBeCloseTo(half - 22);
});
it('derives size and inner radius from the outer radius', () => {
const { size, radius, innerRadius } = getDonutGeometry(300, 300);
expect(size).toBeCloseTo(radius * 2);
expect(innerRadius).toBeCloseTo(radius * 0.6);
});
it('sizes off the smaller dimension so it fits both axes', () => {
expect(getDonutGeometry(1000, 200)).toStrictEqual(
getDonutGeometry(200, 1000),
);
});
it('never returns a negative radius for a box too small for labels', () => {
expect(getDonutGeometry(20, 20).radius).toBe(0);
});
});
describe('getScaledFontSize', () => {
it('returns the base size for empty text', () => {
expect(getScaledFontSize({ text: '', baseSize: 30, innerRadius: 100 })).toBe(

View File

@@ -10,6 +10,16 @@ export interface ScaledFontSizeArgs {
innerRadius: number;
}
/** Donut sizing for a given chart box: the outer/inner radii and the square it spans. */
export interface DonutGeometry {
/** Outer diameter — feeds the visx Pie width/height and the render guard. */
size: number;
/** Outer radius of the donut ring. */
radius: number;
/** Inner radius (the hole) — also bounds the centre-total font. */
innerRadius: number;
}
export interface ArcGeometry {
/** Outer point where the leader label sits. */
labelX: number;

View File

@@ -3,7 +3,37 @@
* so the renderer stays declarative (per the one-component-per-file rule).
*/
import { ArcGeometry, ParsedRgb, ScaledFontSizeArgs } from './types';
import {
ArcGeometry,
DonutGeometry,
ParsedRgb,
ScaledFontSizeArgs,
} from './types';
// Leader-line + two-line label/value drawn outside the donut. `getArcGeometry`
// anchors the label at `radius * LABEL_RADIUS_RATIO`; `LABEL_TEXT_ALLOWANCE` is
// the px reserved beyond that anchor for the (10px, two-line) text so it never
// clips against the SVG edge.
const LABEL_RADIUS_RATIO = 1.3;
const LABEL_TEXT_ALLOWANCE = 22;
const INNER_RADIUS_RATIO = 0.6;
/**
* Sizes the donut to fit inside a `width × height` box *with room for the
* external leader labels*. The label anchor sits at `radius * 1.3`, so we solve
* the outer radius back from the box's half-extent minus the text allowance —
* guaranteeing the labels stay inside the SVG instead of being clipped (V1 used
* a flat `0.35 * min(w,h)`, which left too little margin on small panels).
*/
export function getDonutGeometry(width: number, height: number): DonutGeometry {
const half = Math.min(width, height) / 2;
const radius = Math.max(0, (half - LABEL_TEXT_ALLOWANCE) / LABEL_RADIUS_RATIO);
return {
size: radius * 2,
radius,
innerRadius: radius * INNER_RADIUS_RATIO,
};
}
/**
* Shrinks the centre-total font as the text gets longer so it never overflows
@@ -37,7 +67,7 @@ export function getArcGeometry(
radius: number,
): ArcGeometry {
const angle = (startAngle + endAngle) / 2;
const labelRadius = radius * 1.3;
const labelRadius = radius * LABEL_RADIUS_RATIO;
const lineEndRadius = radius * 1.1;
return {
labelX: Math.sin(angle) * labelRadius,

View File

@@ -0,0 +1,79 @@
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { calculateChartDimensions } from '../utils';
const labels = (count: number, length = 20): string[] =>
Array.from({ length: count }, (_, i) =>
`label-${i}`.padEnd(length, 'x').slice(0, length),
);
describe('calculateChartDimensions', () => {
it('returns all zeros when the container has no space', () => {
expect(
calculateChartDimensions({
containerWidth: 0,
containerHeight: 300,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
}),
).toStrictEqual({
width: 0,
height: 0,
legendWidth: 0,
legendHeight: 0,
averageLegendWidth: 0,
});
});
it('RIGHT: reserves a side column capped at 30% of the width and keeps full height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
// 40-char labels approximate to 336px, capped at min(240, 30% of 1000).
expect(dims.legendWidth).toBe(240);
expect(dims.width).toBe(760);
expect(dims.height).toBe(400);
expect(dims.legendHeight).toBe(400);
});
it('BOTTOM: a single row of items reserves one legend row', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
});
// One row = line height (28) + padding (12).
expect(dims.legendHeight).toBe(40);
expect(dims.height).toBe(460);
expect(dims.legendWidth).toBe(1000);
});
it('BOTTOM: many items cap at two rows on a tall container', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
expect(dims.legendHeight).toBe(68);
expect(dims.height).toBe(432);
});
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 160,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Without the height-relative cap the legend would take 68px of a 160px
// panel and the chart (pie especially) collapses to a sliver.
expect(dims.legendHeight).toBe(48); // 30% of 160
expect(dims.height).toBe(112);
});
});

View File

@@ -116,7 +116,15 @@ export function calculateChartDimensions({
? legendRowCount * legendRowHeight - LEGEND_PADDING
: legendRowHeight;
const maxAllowedLegendHeight = Math.min(2 * legendRowHeight, 80);
// Cap at two rows / 80px, and never more than 30% of the container height
// (the doc above always promised the %-cap; without it, short grid panels
// hand most of their area to the legend and the chart — the pie donut
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
const maxAllowedLegendHeight = Math.min(
2 * legendRowHeight,
80,
Math.floor(containerHeight * 0.3),
);
const bottomLegendHeight = Math.min(
idealBottomLegendHeight,

View File

@@ -7,15 +7,17 @@
&--legend-right {
flex-direction: row;
.chart-layout__legend-wrapper {
padding-left: 0 !important;
}
}
&__legend-wrapper {
// The inline height is the legend rectangle from calculateChartDimensions;
// border-box keeps the padding inside it so the wrapper doesn't grow past
// that height and steal space from the chart. overflow:hidden clips to the
// rectangle so the virtualized legend scrolls within it.
box-sizing: border-box;
min-height: 0;
overflow: hidden;
padding-left: 12px;
padding-bottom: 12px;
overflow: auto;
}
}

View File

@@ -0,0 +1,61 @@
import { act, renderHook } from '@testing-library/react';
import { useConfirmableAction } from '../useConfirmableAction';
describe('useConfirmableAction', () => {
it('starts closed and idle', () => {
const { result } = renderHook(() =>
useConfirmableAction(jest.fn().mockResolvedValue(undefined)),
);
expect(result.current.open).toBe(false);
expect(result.current.isPending).toBe(false);
});
it('request() opens the prompt without running the action', () => {
const action = jest.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useConfirmableAction(action));
act(() => result.current.request());
expect(result.current.open).toBe(true);
expect(action).not.toHaveBeenCalled();
});
it('confirm() runs the action and closes on success', async () => {
const action = jest.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useConfirmableAction(action));
act(() => result.current.request());
await act(async () => {
await result.current.confirm();
});
expect(action).toHaveBeenCalledTimes(1);
expect(result.current.open).toBe(false);
expect(result.current.isPending).toBe(false);
});
it('keeps the prompt open and resets pending when the action rejects', async () => {
const action = jest.fn().mockRejectedValue(new Error('boom'));
const { result } = renderHook(() => useConfirmableAction(action));
act(() => result.current.request());
await act(async () => {
await expect(result.current.confirm()).rejects.toThrow('boom');
});
expect(result.current.open).toBe(true);
expect(result.current.isPending).toBe(false);
});
it('cancel() closes the prompt without running the action', () => {
const action = jest.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useConfirmableAction(action));
act(() => result.current.request());
act(() => result.current.cancel());
expect(result.current.open).toBe(false);
expect(action).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,45 @@
import { useCallback, useMemo, useState } from 'react';
export interface ConfirmableAction {
/** Whether the confirmation prompt is open. */
open: boolean;
/** The confirmed action is in flight. */
isPending: boolean;
/** Open the confirmation prompt (e.g. from a menu item / button). */
request: () => void;
/** Run the action, tracking the in-flight flag; closes the prompt on success. */
confirm: () => Promise<void>;
/** Dismiss the prompt without acting. */
cancel: () => void;
}
/**
* Generic two-step confirm flow for a (usually destructive) async action.
* `request()` opens the prompt, `confirm()` runs `action` while tracking an
* in-flight flag and closes on success, `cancel()` dismisses it. Owns only the
* confirm state machine — what renders the prompt (dialog, popover) is the
* caller's concern, so it stays reusable across confirm surfaces.
*/
export function useConfirmableAction(
action: () => Promise<void>,
): ConfirmableAction {
const [open, setOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
const request = useCallback((): void => setOpen(true), []);
const cancel = useCallback((): void => setOpen(false), []);
const confirm = useCallback(async (): Promise<void> => {
setIsPending(true);
try {
await action();
setOpen(false);
} finally {
setIsPending(false);
}
}, [action]);
return useMemo(
() => ({ open, isPending, request, confirm, cancel }),
[open, isPending, request, confirm, cancel],
);
}

View File

@@ -1,3 +1,5 @@
@use '../../../../styles/scrollbar' as *;
.legend-search-container {
flex-shrink: 0;
width: 100%;
@@ -15,6 +17,10 @@
gap: 12px;
height: 100%;
width: 100%;
// Allow the flex children to shrink below their content height so the
// virtualized grid scrolls within the capped legend height instead of
// overflowing the wrapper (default min-height:auto would block the shrink).
min-height: 0;
&:has(.legend-item-focused) .legend-item {
opacity: 0.3;
@@ -33,6 +39,11 @@
}
.legend-virtuoso-container {
// flex:1 + min-height:0 pins the scroller to the space left after the
// search box (RIGHT legend) and lets it scroll instead of growing to fit
// every row — without this the grid overflows a BOTTOM legend's fixed height.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
@@ -44,7 +55,6 @@
auto-fill,
minmax(var(--legend-average-width, 240px), 1fr)
);
row-gap: 4px;
column-gap: 12px;
}
@@ -68,18 +78,7 @@
}
}
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
border-radius: 0.5rem;
}
@include custom-scrollbar;
}
}
@@ -109,6 +108,10 @@
align-items: center;
gap: 6px;
padding: 4px 8px;
// Include padding within the width so a full-width row (legend-item-right) fits its
// column instead of overflowing by the 16px horizontal padding — there is no global
// border-box reset, so the default content-box would make it overflow.
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
border-radius: 4px;

View File

@@ -87,7 +87,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
lineConfig.fill = `${finalFillColor}40`;
} else if (fillMode && fillMode !== FillMode.None) {
if (fillMode === FillMode.Solid) {
lineConfig.fill = finalFillColor;
lineConfig.fill = `${finalFillColor}70`;
} else if (fillMode === FillMode.Gradient) {
lineConfig.fill = (self: uPlot): CanvasGradient =>
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');

View File

@@ -0,0 +1,90 @@
.config {
display: flex;
flex-direction: column;
flex: 1;
// padding: 18px 18px 44px;
background-color: var(--l1-background);
overflow-y: auto;
overflow-x: hidden;
//TODO: replace this with custom-scrollbar mixin
// Thin, unobtrusive scrollbar (replaces the chunky native bar).
$thumb: color-mix(in srgb, var(--bg-vanilla-100) 16%, transparent);
scrollbar-width: thin;
scrollbar-color: $thumb transparent;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: $thumb;
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
}
.heading {
margin-bottom: 18px;
padding: 16px 16px 0 16px;
}
.title {
display: flex;
align-items: baseline;
gap: 9px;
white-space: nowrap;
}
.subtitle {
font-size: 12px;
color: var(--text-vanilla-400);
}
.eyebrow {
display: block;
margin: 0 2px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--l1-foreground);
}
.group {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.divider {
height: 1px;
background: var(--l2-border);
margin: 18px 0;
}
.sectionsContainer {
padding: 0 16px;
}
.sections {
display: flex;
flex-direction: column;
& > * + * {
border-top: 1px solid var(--l2-border);
}
}

View File

@@ -0,0 +1,111 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import SectionSlot from './SectionSlot/SectionSlot';
import styles from './ConfigPane.module.scss';
import { PanelKind } from '../../Panels/types/panelKind';
interface ConfigPaneProps {
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); drives which sections show. */
panelKind: PanelKind;
/** The panel spec — the single editing surface (title/description + section slices). */
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Panel's resolved series, provided to sections that need them (legend colors). */
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
tableColumns: TableColumnOption[];
}
/**
* Right-hand configuration pane. Renders the always-present general fields (title +
* description) followed by the panel kind's configuration sections (Formatting, Axes,
* …). The section list is declared per kind (`PanelDefinition.sections`) and rendered
* generically via the section registry — only sections with a built editor appear.
*/
function ConfigPane({
panelKind,
spec,
onChangeSpec,
legendSeries,
tableColumns,
}: ConfigPaneProps): JSX.Element {
const definition = getPanelDefinition(panelKind);
const sections = definition?.sections ?? [];
// Telemetry signal of the panel's first builder query — scopes field-key
// suggestions for editors that need them (the List column picker). The v5
// `signal` literal matches the TelemetrytypesSignalDTO values.
const signal = getBuilderQueries(spec.queries)[0]?.signal as
| TelemetrytypesSignalDTO
| undefined;
// Title/description are just a slice of the spec — edit them through the same
// onChangeSpec path the sections use, so there's a single editing surface.
const setDisplayField = (field: 'name' | 'description', value: string): void =>
onChangeSpec({ ...spec, display: { ...spec.display, [field]: value } });
return (
<div className={styles.config}>
<header className={styles.heading}>
<Typography.Text>Panel settings</Typography.Text>
</header>
<div className={styles.group}>
<div className={styles.field}>
<Typography.Text>Title</Typography.Text>
<Input
data-testid="panel-editor-v2-title"
value={spec.display?.name ?? ''}
placeholder="Panel title"
onChange={(e): void => setDisplayField('name', e.target.value)}
/>
</div>
<div className={styles.field}>
<Typography.Text>Description</Typography.Text>
<Input.TextArea
data-testid="panel-editor-v2-description"
value={spec.display?.description ?? ''}
placeholder="Add a description"
rows={3}
onChange={(e): void => setDisplayField('description', e.target.value)}
/>
</div>
</div>
{sections.length > 0 && (
<>
<div className={styles.divider} />
<div className={styles.sectionsContainer}>
<span className={styles.eyebrow}>Display</span>
<div className={styles.sections}>
{sections.map((config) => (
<SectionSlot
key={config.kind}
config={config}
spec={spec}
onChangeSpec={onChangeSpec}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
/>
))}
</div>
</div>
</>
)}
</div>
);
}
export default ConfigPane;

View File

@@ -0,0 +1,77 @@
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
SECTION_METADATA,
type SectionConfig,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { LegendSeries } from '../../hooks/useLegendSeries';
import type { TableColumnOption } from '../../hooks/useTableColumns';
import { resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
interface SectionSlotProps {
config: SectionConfig;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Resolved series, forwarded to editors that need them (legend colors). */
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
tableColumns: TableColumnOption[];
/** Panel's telemetry signal, for editors that fetch field suggestions (List columns). */
signal?: TelemetrytypesSignalDTO;
}
/**
* Renders one configuration section: its collapsible wrapper plus the registered editor
* for `config.kind`, wired through the registry's spec lens. Renders nothing when the
* kind has no editor yet (sections roll out incrementally), so a kind can declare a
* section before its editor exists.
*/
function SectionSlot({
config,
spec,
onChangeSpec,
legendSeries,
tableColumns,
signal,
}: SectionSlotProps): JSX.Element | null {
// A kind can hide a section based on current spec state (e.g. Histogram legend once
// queries are merged) — skip it before resolving the editor.
if (config.isHidden?.(spec)) {
return null;
}
const editor = resolveSectionEditor(config.kind);
if (!editor) {
return null;
}
const { title, icon: Icon } = SECTION_METADATA[config.kind];
const { Component, read, write } = editor;
// Atomic sections carry no `controls`; controlled ones do.
const controls = 'controls' in config ? config.controls : undefined;
// The panel's formatting unit, forwarded to editors that scope to it (thresholds
// restrict their unit picker to this unit's category, as in V1).
const yAxisUnit = (
spec.plugin?.spec as { formatting?: { unit?: string } } | undefined
)?.formatting?.unit;
return (
<SettingsSection title={title} icon={<Icon size={15} />}>
<Component
value={read(spec)}
controls={controls}
onChange={(next): void => onChangeSpec(write(spec, next))}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
/>
</SettingsSection>
);
}
export default SectionSlot;

View File

@@ -0,0 +1,54 @@
.header {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
height: 44px;
padding: 0 4px;
border: none;
background: transparent;
cursor: pointer;
color: var(--text-vanilla-100);
border-radius: 4px;
}
.iconTile {
display: grid;
place-items: center;
width: 27px;
height: 27px;
flex: none;
border-radius: 3px;
background: var(--l3-background);
color: var(--l3-foreground);
transition: all 0.15s ease;
}
.iconTileOpen {
background: color-mix(in srgb, var(--bg-robin-400) 14%, transparent);
color: var(--bg-robin-400);
}
.title {
flex: 1;
text-align: left;
font-weight: 600;
color: var(--l2-foreground);
}
.chevron {
flex: none;
color: var(--l2-border);
transition: transform 0.15s ease;
&.open {
transform: rotate(180deg);
}
}
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 2px 0 18px;
}

View File

@@ -0,0 +1,54 @@
import { type ReactNode, useState } from 'react';
import { ChevronDown } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './SettingsSection.module.scss';
interface SettingsSectionProps {
title: string;
icon?: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
}
/**
* Collapsible container for one configuration section in the V2 panel editor's
* ConfigPane. Header shows an icon tile (accented when expanded), the title, and a
* rotating chevron; sections are separated by hairline dividers (no surrounding boxes),
* matching the Configure-panel design.
*/
function SettingsSection({
title,
icon,
defaultOpen = false,
children,
}: SettingsSectionProps): JSX.Element {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<section className={styles.section}>
<button
type="button"
className={styles.header}
aria-expanded={isOpen}
data-testid={`config-section-${title}`}
onClick={(): void => setIsOpen((prev) => !prev)}
>
{icon && (
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
{icon}
</span>
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
<ChevronDown
size={15}
className={cx(styles.chevron, { [styles.open]: isOpen })}
/>
</button>
{isOpen && <div className={styles.body}>{children}</div>}
</section>
);
}
export default SettingsSection;

View File

@@ -0,0 +1,69 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigPane from '../ConfigPane';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
function spec(unit?: string): DashboardtypesPanelSpecDTO {
return {
display: { name: 'CPU', description: 'usage' },
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: unit ? { formatting: { unit } } : {},
},
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
function renderConfigPane(
overrides: Partial<React.ComponentProps<typeof ConfigPane>> = {},
): React.ComponentProps<typeof ConfigPane> {
const props: React.ComponentProps<typeof ConfigPane> = {
panelKind: 'signoz/TimeSeriesPanel',
spec: spec(),
onChangeSpec: jest.fn(),
legendSeries: [],
tableColumns: [],
...overrides,
};
render(<ConfigPane {...props} />);
return props;
}
describe('ConfigPane', () => {
it('renders the seeded title and description', () => {
renderConfigPane();
expect(screen.getByTestId('panel-editor-v2-title')).toHaveValue('CPU');
expect(screen.getByTestId('panel-editor-v2-description')).toHaveValue(
'usage',
);
});
it('reports title edits through onChangeSpec (into spec.display)', () => {
const { onChangeSpec } = renderConfigPane();
fireEvent.change(screen.getByTestId('panel-editor-v2-title'), {
target: { value: 'Memory' },
});
expect(onChangeSpec).toHaveBeenCalledWith(
expect.objectContaining({
display: { name: 'Memory', description: 'usage' },
}),
);
});
it('renders the Formatting section for a kind that declares it', () => {
renderConfigPane();
// The TimeSeries kind declares a Formatting section; its collapsible header shows.
expect(screen.getByTestId('config-section-Formatting')).toBeInTheDocument();
});
it('omits the Formatting section for an unknown kind', () => {
renderConfigPane({ panelKind: 'signoz/UnknownPanel' as PanelKind });
expect(
screen.queryByTestId('config-section-Formatting'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,10 @@
.group {
width: min(350px, 100%);
}
.segment {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}

View File

@@ -0,0 +1,59 @@
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
import styles from './ConfigSegmented.module.scss';
export interface ConfigSegmentedItem {
value: string;
label: string;
icon?: SegmentIconName;
}
interface ConfigSegmentedProps {
testId: string;
value: string | undefined;
items: ConfigSegmentedItem[];
onChange: (value: string) => void;
}
/**
* Inline segmented control for short option sets in the config pane (line style, fill
* mode, axis scale, legend position). Each segment carries an optional muted glyph that
* brightens with the selected state (it inherits the toggle's `currentColor`). Built on
* the Periscope ToggleGroup so it stays theme-faithful.
*/
function ConfigSegmented({
testId,
value,
items,
onChange,
}: ConfigSegmentedProps): JSX.Element {
return (
<ToggleGroupSimple
type="single"
testId={testId}
className={styles.group}
value={value}
items={items.map((item) => ({
value: item.value,
'aria-label': item.label,
label: (
<span className={styles.segment}>
{item.icon && <SegmentIcon name={item.icon} />}
{item.label}
</span>
),
}))}
// Single toggle-groups emit '' when the active segment is re-clicked; ignore that
// so a required choice (e.g. scale, position) can't be cleared to an empty value.
onChange={(next: string): void => {
if (next) {
onChange(next);
}
}}
/>
);
}
export default ConfigSegmented;

View File

@@ -0,0 +1,10 @@
// Fill the section field so the select lines up with the other full-width controls.
.select {
width: 100%;
}
.item {
display: inline-flex;
align-items: center;
gap: 9px;
}

View File

@@ -0,0 +1,56 @@
import { Select } from 'antd';
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
import styles from './ConfigSelect.module.scss';
export interface ConfigSelectItem {
value: string;
label: string;
icon?: SegmentIconName;
}
interface ConfigSelectProps {
testId: string;
value: string | undefined;
placeholder?: string;
items: ConfigSelectItem[];
onChange: (value: string) => void;
}
/**
* Single-select dropdown for the panel editor's config sections. Built on antd's
* `Select` so it matches the rest of the editor's antd controls; the menu portals to
* `document.body` (antd default) so the surrounding `overflow:auto` pane can't clip it.
*/
function ConfigSelect({
testId,
value,
placeholder,
items,
onChange,
}: ConfigSelectProps): JSX.Element {
return (
<Select<string>
className={styles.select}
data-testid={testId}
value={value}
placeholder={placeholder}
onChange={onChange}
virtual={false}
options={items.map((item) => ({
value: item.value,
label: item.icon ? (
<span className={styles.item}>
<SegmentIcon name={item.icon} />
{item.label}
</span>
) : (
item.label
),
}))}
/>
);
}
export default ConfigSelect;

View File

@@ -0,0 +1,30 @@
.card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l2-background-60);
}
.text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--l2-foreground);
}
.description {
font-size: 12px;
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,43 @@
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import styles from './ConfigSwitch.module.scss';
interface ConfigSwitchProps {
testId: string;
/** Shown uppercased as the card title. */
title: string;
/** Optional helper line under the title. */
description?: string;
value: boolean;
onChange: (checked: boolean) => void;
}
/**
* Boolean toggle rendered as a bordered card: an uppercase title with an optional
* description on the left and a Switch on the right. The standard presentation for
* on/off panel-config controls (e.g. "Show points").
*/
function ConfigSwitch({
testId,
title,
description,
value,
onChange,
}: ConfigSwitchProps): JSX.Element {
return (
<div className={styles.card}>
<div className={styles.text}>
<span className={styles.title}>{title}</span>
{description && (
<Typography.Text className={styles.description}>
{description}
</Typography.Text>
)}
</div>
<Switch testId={testId} value={value} onChange={onChange} />
</div>
);
}
export default ConfigSwitch;

View File

@@ -0,0 +1,62 @@
import { ColorPicker } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styles from './LegendColors.module.scss';
interface LegendColorRowProps {
label: string;
/** Effective color shown in the swatch (override or auto). */
color: string;
/** True when the series has an explicit override (enables Reset). */
isOverridden: boolean;
onChange: (hex: string) => void;
onReset: () => void;
}
/**
* One series row in the legend-colors list: an antd ColorPicker swatch trigger, the
* series label, and a Reset action shown only when the color is overridden. `onChange`
* fires on commit (`onChangeComplete`) so dragging the picker doesn't churn the spec.
*/
function LegendColorRow({
label,
color,
isOverridden,
onChange,
onReset,
}: LegendColorRowProps): JSX.Element {
return (
<div className={styles.row}>
<ColorPicker
value={color}
size="small"
showText={false}
trigger="click"
onChangeComplete={(next): void => onChange(next.toHexString())}
>
<button
type="button"
className={styles.trigger}
data-testid={`legend-color-${label}`}
>
<span className={styles.swatch} style={{ backgroundColor: color }} />
<Typography.Text className={styles.label} title={label}>
{label}
</Typography.Text>
</button>
</ColorPicker>
{isOverridden && (
<button
type="button"
className={styles.reset}
onClick={onReset}
data-testid={`legend-color-reset-${label}`}
>
Reset
</button>
)}
</div>
);
}
export default LegendColorRow;

View File

@@ -0,0 +1,61 @@
.container {
display: flex;
flex-direction: column;
gap: 8px;
}
.list {
width: 100%;
}
.row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
height: 34px;
}
.trigger {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
}
.swatch {
width: 18px;
height: 18px;
flex: none;
border: 1px solid var(--l2-border);
border-radius: 4px;
}
.label {
overflow: hidden;
font-size: 12px;
color: var(--l2-foreground);
white-space: nowrap;
text-overflow: ellipsis;
}
.reset {
flex: none;
padding: 0;
border: none;
background: transparent;
color: var(--bg-robin-400);
font-size: 12px;
cursor: pointer;
}
.empty {
font-size: 12px;
color: var(--text-vanilla-400);
}

View File

@@ -0,0 +1,83 @@
import { useState } from 'react';
import { Search } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import { Virtuoso } from 'react-virtuoso';
import type { LegendSeries } from '../../../hooks/useLegendSeries';
import LegendColorRow from './LegendColorRow';
import {
clearSeriesColor,
filterLegendSeries,
resolveSeriesColor,
setSeriesColor,
} from './legendColors.utils';
import styles from './LegendColors.module.scss';
interface LegendColorsProps {
/** Panel's resolved series (from the shared preview query). */
series: LegendSeries[];
value: DashboardtypesLegendDTOCustomColors | undefined;
onChange: (next: Record<string, string>) => void;
}
/**
* Per-series color overrides for the legend: a searchable, virtualized list of the
* panel's resolved series, each with an antd ColorPicker swatch. Picking a color writes
* `{ [seriesLabel]: hex }` into `legend.customColors` — the same label the chart keys its
* color lookup on; Reset drops the override. Virtualized so panels with hundreds of
* series stay responsive. Until the query produces series, shows a hint.
*/
function LegendColors({
series,
value,
onChange,
}: LegendColorsProps): JSX.Element {
const [query, setQuery] = useState('');
if (series.length === 0) {
return (
<Typography.Text className={styles.empty}>
Run the panel to customise series colors.
</Typography.Text>
);
}
const filtered = filterLegendSeries(series, query);
return (
<div className={styles.container} data-testid="panel-editor-v2-legend-colors">
<Input
data-testid="panel-editor-v2-legend-search"
placeholder="Search series…"
value={query}
prefix={<Search size={14} />}
onChange={(e): void => setQuery(e.target.value)}
/>
{filtered.length === 0 ? (
<Typography.Text className={styles.empty}>
No series match {query}.
</Typography.Text>
) : (
<Virtuoso
className={styles.list}
style={{ height: Math.min(filtered.length * 34, 240) }}
data={filtered}
itemContent={(_, s): JSX.Element => (
<LegendColorRow
label={s.label}
color={resolveSeriesColor(value, s.label, s.defaultColor)}
isOverridden={value?.[s.label] !== undefined}
onChange={(hex): void => onChange(setSeriesColor(value, s.label, hex))}
onReset={(): void => onChange(clearSeriesColor(value, s.label))}
/>
)}
/>
)}
</div>
);
}
export default LegendColors;

View File

@@ -0,0 +1,42 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
import LegendColors from '../LegendColors';
const SERIES: LegendSeries[] = [
{ label: 'frontend', defaultColor: '#ff0000' },
{ label: 'cartservice', defaultColor: '#00ff00' },
];
describe('LegendColors', () => {
it('shows a hint when there are no resolved series', () => {
render(<LegendColors series={[]} value={undefined} onChange={jest.fn()} />);
expect(
screen.queryByTestId('panel-editor-v2-legend-colors'),
).not.toBeInTheDocument();
expect(screen.getByText(/run the panel/i)).toBeInTheDocument();
});
it('renders the search box once series are present', () => {
render(
<LegendColors series={SERIES} value={undefined} onChange={jest.fn()} />,
);
expect(
screen.getByTestId('panel-editor-v2-legend-search'),
).toBeInTheDocument();
});
it('shows a no-match message when the search filters everything out', () => {
render(
<LegendColors series={SERIES} value={undefined} onChange={jest.fn()} />,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-legend-search'), {
target: { value: 'zzz' },
});
expect(screen.getByText(/no series match/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,63 @@
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
import {
clearSeriesColor,
filterLegendSeries,
resolveSeriesColor,
setSeriesColor,
} from '../legendColors.utils';
const SERIES: LegendSeries[] = [
{ label: 'frontend', defaultColor: '#ff0000' },
{ label: 'cartservice', defaultColor: '#00ff00' },
{ label: 'frontendproxy', defaultColor: '#0000ff' },
];
describe('legendColors.utils', () => {
describe('filterLegendSeries', () => {
it('returns all series for an empty/whitespace query', () => {
expect(filterLegendSeries(SERIES, '')).toHaveLength(3);
expect(filterLegendSeries(SERIES, ' ')).toHaveLength(3);
});
it('matches case-insensitive substrings', () => {
expect(
filterLegendSeries(SERIES, 'FRONT').map((s) => s.label),
).toStrictEqual(['frontend', 'frontendproxy']);
expect(filterLegendSeries(SERIES, 'cart')).toHaveLength(1);
expect(filterLegendSeries(SERIES, 'zzz')).toHaveLength(0);
});
});
describe('resolveSeriesColor', () => {
it('prefers the override, falling back to the default', () => {
expect(resolveSeriesColor({ frontend: '#111' }, 'frontend', '#ff0000')).toBe(
'#111',
);
expect(resolveSeriesColor(undefined, 'frontend', '#ff0000')).toBe('#ff0000');
expect(resolveSeriesColor(null, 'frontend', '#ff0000')).toBe('#ff0000');
});
});
describe('setSeriesColor', () => {
it('adds/overwrites a label without mutating the input', () => {
const value = { frontend: '#111' };
const next = setSeriesColor(value, 'cartservice', '#222');
expect(next).toStrictEqual({ frontend: '#111', cartservice: '#222' });
expect(value).toStrictEqual({ frontend: '#111' });
});
it('handles null/undefined base', () => {
expect(setSeriesColor(undefined, 'a', '#1')).toStrictEqual({ a: '#1' });
expect(setSeriesColor(null, 'a', '#1')).toStrictEqual({ a: '#1' });
});
});
describe('clearSeriesColor', () => {
it('removes a label without mutating the input', () => {
const value = { frontend: '#111', cartservice: '#222' };
const next = clearSeriesColor(value, 'frontend');
expect(next).toStrictEqual({ cartservice: '#222' });
expect(value).toStrictEqual({ frontend: '#111', cartservice: '#222' });
});
});
});

View File

@@ -0,0 +1,43 @@
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import type { LegendSeries } from '../../../hooks/useLegendSeries';
/** Case-insensitive substring filter over series labels. Empty query → all series. */
export function filterLegendSeries(
series: LegendSeries[],
query: string,
): LegendSeries[] {
const q = query.trim().toLowerCase();
if (!q) {
return series;
}
return series.filter((s) => s.label.toLowerCase().includes(q));
}
/** The effective color for a series: the override if set, else its auto color. */
export function resolveSeriesColor(
value: DashboardtypesLegendDTOCustomColors | undefined,
label: string,
defaultColor: string,
): string {
return value?.[label] ?? defaultColor;
}
/** Set an override for `label`, returning a new customColors record. */
export function setSeriesColor(
value: DashboardtypesLegendDTOCustomColors | undefined,
label: string,
hex: string,
): Record<string, string> {
return { ...value, [label]: hex };
}
/** Drop the override for `label` (revert to the auto color), returning a new record. */
export function clearSeriesColor(
value: DashboardtypesLegendDTOCustomColors | undefined,
label: string,
): Record<string, string> {
const next = { ...value };
delete next[label];
return next;
}

View File

@@ -0,0 +1,145 @@
/**
* Small glyph icons for the panel-editor segmented/select controls, ported from the
* Configure-panel design. They render at 14px and inherit `currentColor` so the
* surrounding control can dim them when unselected and brighten them when active.
*/
export type SegmentIconName =
| 'solid-line'
| 'dashed-line'
| 'fill-none'
| 'fill-solid'
| 'fill-gradient'
| 'pos-bottom'
| 'pos-right'
| 'scale-linear'
| 'scale-log'
| 'interp-linear'
| 'interp-spline'
| 'interp-step-before'
| 'interp-step-after';
function Svg({ children }: { children: React.ReactNode }): JSX.Element {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
style={{ flex: 'none' }}
aria-hidden
>
{children}
</svg>
);
}
const FILLED = { fill: 'currentColor', stroke: 'none' } as const;
export function SegmentIcon({
name,
}: {
name: SegmentIconName;
}): JSX.Element | null {
switch (name) {
case 'solid-line':
return (
<Svg>
<path d="M2 8 H14" />
</Svg>
);
case 'dashed-line':
return (
<Svg>
<path d="M2 8 H4.5" />
<path d="M6.75 8 H9.25" />
<path d="M11.5 8 H14" />
</Svg>
);
case 'fill-none':
return (
<Svg>
<path d="M2 11 L6 6 L10 9 L14 5" />
</Svg>
);
case 'fill-solid':
return (
<Svg>
<path
d="M2 10.5 L6 5.5 L10 8.5 L14 4.5 V13.5 H2 Z"
fill="currentColor"
fillOpacity={0.85}
stroke="none"
/>
<path d="M2 10.5 L6 5.5 L10 8.5 L14 4.5" />
</Svg>
);
case 'fill-gradient':
return (
<Svg>
<path
d="M2 10.5 L6 5.5 L10 8.5 L14 4.5 V13.5 H2 Z"
fill="currentColor"
fillOpacity={0.3}
stroke="none"
/>
<path d="M2 10.5 L6 5.5 L10 8.5 L14 4.5" />
</Svg>
);
case 'pos-bottom':
return (
<Svg>
<rect x={2} y={2.5} width={12} height={9} rx={1.2} />
<rect x={2} y={9} width={12} height={2.5} {...FILLED} />
</Svg>
);
case 'pos-right':
return (
<Svg>
<rect x={2} y={2.5} width={12} height={9} rx={1.2} />
<rect x={10.5} y={2.5} width={3.5} height={9} {...FILLED} />
</Svg>
);
case 'scale-linear':
return (
<Svg>
<path d="M2.5 13 L13.5 3" />
</Svg>
);
case 'scale-log':
return (
<Svg>
<path d="M2.5 13 C5 13, 8 4.5, 13.5 3" />
</Svg>
);
case 'interp-linear':
return (
<Svg>
<path d="M2 12 L6 5 L10 9 L14 4" />
</Svg>
);
case 'interp-spline':
return (
<Svg>
<path d="M2 12 C5 3, 9 3, 14 8" />
</Svg>
);
case 'interp-step-before':
return (
<Svg>
<path d="M2 6 H6 V10 H10 V4.5 H14" />
</Svg>
);
case 'interp-step-after':
return (
<Svg>
<path d="M2 10 H6 V5 H10 V9.5 H14" />
</Svg>
);
default:
return null;
}
}

View File

@@ -0,0 +1,172 @@
import type { ComponentType } from 'react';
import type {
DashboardLinkDTO,
DashboardtypesAxesDTO,
DashboardtypesBarChartVisualizationDTO,
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesTimeSeriesChartAppearanceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
AnyThreshold,
PanelFormattingSlice,
SectionEditorProps,
SectionKind,
SectionSpecMap,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import AxesSection from './sections/AxesSection/AxesSection';
import BucketsSection from './sections/BucketsSection/BucketsSection';
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import LegendSection from './sections/LegendSection/LegendSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
type PanelSpec = DashboardtypesPanelSpecDTO;
/**
* Pairs a section kind with its editor component and a typed lens into the panel spec.
* The lens reads/writes over the WHOLE panel spec, so a section can target either the
* plugin spec (`spec.plugin.spec.<key>`) or a panel-level field (e.g. `spec.links`).
*/
export interface SectionDescriptor<K extends SectionKind> {
Component: ComponentType<SectionEditorProps<K>>;
read: (spec: PanelSpec) => SectionSpecMap[K] | undefined;
write: (spec: PanelSpec, value: SectionSpecMap[K]) => PanelSpec;
}
// The plugin spec is a discriminated union over panel kinds; reading/writing a shared
// slice (formatting, axes, …) by key is the one place the union must be narrowed. The
// helper concentrates that cast so the registry entries stay declarative.
type PluginSpecSlice = Partial<Record<string, unknown>>;
function readPluginSlice<T>(spec: PanelSpec, key: string): T | undefined {
return (spec.plugin?.spec as PluginSpecSlice | undefined)?.[key] as
| T
| undefined;
}
function writePluginSlice(
spec: PanelSpec,
key: string,
value: unknown,
): PanelSpec {
return {
...spec,
plugin: {
...spec.plugin,
spec: { ...(spec.plugin?.spec as PluginSpecSlice), [key]: value },
},
} as PanelSpec;
}
/**
* Registry of section editors. Partial by design: only sections with a built editor
* appear here, so ConfigPane renders exactly those and silently skips the rest. Adding
* a section editor = one entry here + one component file.
*/
export const SECTION_REGISTRY: {
[K in SectionKind]?: SectionDescriptor<K>;
} = {
formatting: {
Component: FormattingSection,
read: (spec): PanelFormattingSlice | undefined =>
readPluginSlice<PanelFormattingSlice>(spec, 'formatting'),
write: (spec, formatting): PanelSpec =>
writePluginSlice(spec, 'formatting', formatting),
},
axes: {
Component: AxesSection,
read: (spec): DashboardtypesAxesDTO | undefined =>
readPluginSlice<DashboardtypesAxesDTO>(spec, 'axes'),
write: (spec, axes): PanelSpec => writePluginSlice(spec, 'axes', axes),
},
legend: {
Component: LegendSection,
read: (spec): DashboardtypesLegendDTO | undefined =>
readPluginSlice<DashboardtypesLegendDTO>(spec, 'legend'),
write: (spec, legend): PanelSpec => writePluginSlice(spec, 'legend', legend),
},
chartAppearance: {
Component: ChartAppearanceSection,
read: (spec): DashboardtypesTimeSeriesChartAppearanceDTO | undefined =>
readPluginSlice<DashboardtypesTimeSeriesChartAppearanceDTO>(
spec,
'chartAppearance',
),
write: (spec, chartAppearance): PanelSpec =>
writePluginSlice(spec, 'chartAppearance', chartAppearance),
},
visualization: {
Component: VisualizationSection,
read: (spec): DashboardtypesBarChartVisualizationDTO | undefined =>
readPluginSlice<DashboardtypesBarChartVisualizationDTO>(
spec,
'visualization',
),
write: (spec, visualization): PanelSpec =>
writePluginSlice(spec, 'visualization', visualization),
},
buckets: {
Component: BucketsSection,
read: (spec): DashboardtypesHistogramBucketsDTO | undefined =>
readPluginSlice<DashboardtypesHistogramBucketsDTO>(spec, 'histogramBuckets'),
write: (spec, buckets): PanelSpec =>
writePluginSlice(spec, 'histogramBuckets', buckets),
},
contextLinks: {
Component: ContextLinksSection,
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
read: (spec): DashboardLinkDTO[] | undefined => spec.links,
write: (spec, links): PanelSpec => ({ ...spec, links }),
},
// One editor for every threshold variant (label / comparison / table); the kind's
// `controls.variant` picks the row editor + element shape. All persist to the same
// plugin.spec.thresholds key.
thresholds: {
Component: ThresholdsSection,
read: (spec): AnyThreshold[] | undefined =>
readPluginSlice<AnyThreshold[]>(spec, 'thresholds'),
write: (spec, thresholds): PanelSpec =>
writePluginSlice(spec, 'thresholds', thresholds),
},
};
/**
* A section descriptor with the kind correlation erased. `SECTION_REGISTRY[kind]` and a
* `SectionConfig` are both unions keyed by the same `kind`, but TS can't prove the lookup
* and the config refer to the same member — the classic correlated-union limitation. The
* resolver below narrows once here (the single localized cast), so render sites compose
* `read` → `Component` → `write` without any further casts.
*/
export interface ErasedSectionDescriptor {
Component: ComponentType<{
value: unknown;
controls?: unknown;
onChange: (next: unknown) => void;
// Forwarded to every editor; only sections that need the panel's resolved series
// (legend colors) read it. Optional so editors can ignore it.
legendSeries?: unknown;
// The panel's formatting unit; read by editors that scope to it (thresholds).
yAxisUnit?: unknown;
// The Table panel's resolved value columns; read by the table-only editors
// (column units, per-column thresholds) to offer real columns.
tableColumns?: unknown;
// The panel's telemetry signal; read by editors that fetch field-key
// suggestions scoped to it (List column picker).
signal?: unknown;
}>;
read: (spec: PanelSpec) => unknown;
write: (spec: PanelSpec, value: unknown) => PanelSpec;
}
export function resolveSectionEditor(
kind: SectionKind,
): ErasedSectionDescriptor | undefined {
return SECTION_REGISTRY[kind] as unknown as
| ErasedSectionDescriptor
| undefined;
}

View File

@@ -0,0 +1,11 @@
.bounds {
display: flex;
gap: 8px;
}
.field {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
}

View File

@@ -0,0 +1,80 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import styles from './AxesSection.module.scss';
type SoftBound = 'softMin' | 'softMax';
const SCALE_OPTIONS = [
{ value: 'linear', label: 'Linear', icon: 'scale-linear' as const },
{ value: 'log', label: 'Log', icon: 'scale-log' as const },
];
/**
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds and the
* linear/logarithmic scale toggle. Each control is gated by its `controls` flag.
*/
function AxesSection({
value,
controls,
onChange,
}: SectionEditorProps<'axes'>): JSX.Element {
// An empty field clears the bound (null); otherwise parse to a number, ignoring
// transient non-numeric input (e.g. a lone "-") by leaving the bound unset.
const handleBound =
(bound: SoftBound) =>
(e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
onChange({ ...value, [bound]: next });
};
return (
<>
{controls.minMax && (
<div className={styles.bounds}>
<div className={styles.field}>
<Typography.Text>Soft min</Typography.Text>
<Input
data-testid="panel-editor-v2-soft-min"
type="number"
placeholder="Auto"
value={value?.softMin ?? ''}
onChange={handleBound('softMin')}
/>
</div>
<div className={styles.field}>
<Typography.Text>Soft max</Typography.Text>
<Input
data-testid="panel-editor-v2-soft-max"
type="number"
placeholder="Auto"
value={value?.softMax ?? ''}
onChange={handleBound('softMax')}
/>
</div>
</div>
)}
{controls.logScale && (
<div className={styles.field}>
<Typography.Text>Y-axis scale</Typography.Text>
<ConfigSegmented
testId="panel-editor-v2-log-scale"
value={value?.isLogScale ? 'log' : 'linear'}
items={SCALE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, isLogScale: next === 'log' })
}
/>
</div>
)}
</>
);
}
export default AxesSection;

View File

@@ -0,0 +1,83 @@
import { fireEvent, render, screen } from '@testing-library/react';
import AxesSection from '../AxesSection';
describe('AxesSection', () => {
it('renders soft bounds and the log-scale switch when both controls are enabled', () => {
render(
<AxesSection
value={undefined}
controls={{ minMax: true, logScale: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-soft-min')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-soft-max')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-log-scale')).toBeInTheDocument();
});
it('hides the soft bounds when minMax is off', () => {
render(
<AxesSection
value={undefined}
controls={{ logScale: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-soft-min'),
).not.toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-log-scale')).toBeInTheDocument();
});
it('writes a numeric soft min through onChange', () => {
const onChange = jest.fn();
render(
<AxesSection
value={undefined}
controls={{ minMax: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-soft-min'), {
target: { value: '5' },
});
expect(onChange).toHaveBeenCalledWith({ softMin: 5 });
});
it('clears a soft bound to null when the field is emptied', () => {
const onChange = jest.fn();
render(
<AxesSection
value={{ softMax: 100 }}
controls={{ minMax: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-soft-max'), {
target: { value: '' },
});
expect(onChange).toHaveBeenCalledWith({ softMax: null });
});
it('toggles the logarithmic scale through onChange', () => {
const onChange = jest.fn();
render(
<AxesSection
value={{ isLogScale: false }}
controls={{ logScale: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('Log'));
expect(onChange).toHaveBeenCalledWith({ isLogScale: true });
});
});

View File

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

View File

@@ -0,0 +1,75 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import styles from './BucketsSection.module.scss';
type NumericBound = 'bucketCount' | 'bucketWidth';
/**
* Edits the `histogramBuckets` slice of a Histogram panel spec: bucket count / width
* and whether to merge all active queries into one set of buckets. Each control is gated
* by its `controls` flag.
*/
function BucketsSection({
value,
controls,
onChange,
}: SectionEditorProps<'buckets'>): JSX.Element {
// Empty clears the bound to null (chart auto-sizes); otherwise parse to a number,
// ignoring transient non-numeric input by leaving it unset.
const handleNumber =
(bound: NumericBound) =>
(e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
onChange({ ...value, [bound]: next });
};
return (
<>
{controls.count && (
<div className={styles.field}>
<Typography.Text>Bucket count</Typography.Text>
<Input
data-testid="panel-editor-v2-bucket-count"
type="number"
placeholder="Auto"
value={value?.bucketCount ?? ''}
onChange={handleNumber('bucketCount')}
/>
</div>
)}
{controls.width && (
<div className={styles.field}>
<Typography.Text>Bucket width</Typography.Text>
<Input
data-testid="panel-editor-v2-bucket-width"
type="number"
placeholder="Auto"
value={value?.bucketWidth ?? ''}
onChange={handleNumber('bucketWidth')}
/>
</div>
)}
{controls.mergeQueries && (
<ConfigSwitch
testId="panel-editor-v2-merge-queries"
title="Merge active queries"
description="Bucket all active queries together into one distribution"
value={value?.mergeAllActiveQueries ?? false}
onChange={(checked): void =>
onChange({ ...value, mergeAllActiveQueries: checked })
}
/>
)}
</>
);
}
export default BucketsSection;

View File

@@ -0,0 +1,68 @@
import { fireEvent, render, screen } from '@testing-library/react';
import BucketsSection from '../BucketsSection';
describe('BucketsSection', () => {
it('renders only the controls whose flag is set', () => {
render(
<BucketsSection
value={undefined}
controls={{ count: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-bucket-count'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-bucket-width'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-merge-queries'),
).not.toBeInTheDocument();
});
it('writes a numeric bucket count and clears it to null when emptied', () => {
const onChange = jest.fn();
const { rerender } = render(
<BucketsSection
value={undefined}
controls={{ count: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-bucket-count'), {
target: { value: '20' },
});
expect(onChange).toHaveBeenLastCalledWith({ bucketCount: 20 });
rerender(
<BucketsSection
value={{ bucketCount: 20 }}
controls={{ count: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-bucket-count'), {
target: { value: '' },
});
expect(onChange).toHaveBeenLastCalledWith({ bucketCount: null });
});
it('toggles merge-active-queries through onChange', () => {
const onChange = jest.fn();
render(
<BucketsSection
value={{ mergeAllActiveQueries: false }}
controls={{ mergeQueries: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByTestId('panel-editor-v2-merge-queries'));
expect(onChange).toHaveBeenCalledWith({ mergeAllActiveQueries: true });
});
});

View File

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

View File

@@ -0,0 +1,164 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import {
DashboardtypesFillModeDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import styles from './ChartAppearanceSection.module.scss';
const LINE_STYLE_OPTIONS = [
{
value: DashboardtypesLineStyleDTO.solid,
label: 'Solid',
icon: 'solid-line' as const,
},
{
value: DashboardtypesLineStyleDTO.dashed,
label: 'Dashed',
icon: 'dashed-line' as const,
},
];
const LINE_INTERPOLATION_OPTIONS = [
{
value: DashboardtypesLineInterpolationDTO.linear,
label: 'Linear',
icon: 'interp-linear' as const,
},
{
value: DashboardtypesLineInterpolationDTO.spline,
label: 'Spline',
icon: 'interp-spline' as const,
},
{
value: DashboardtypesLineInterpolationDTO.step_before,
label: 'Step before',
icon: 'interp-step-before' as const,
},
{
value: DashboardtypesLineInterpolationDTO.step_after,
label: 'Step after',
icon: 'interp-step-after' as const,
},
];
const FILL_MODE_OPTIONS = [
{
value: DashboardtypesFillModeDTO.none,
label: 'None',
icon: 'fill-none' as const,
},
{
value: DashboardtypesFillModeDTO.solid,
label: 'Solid',
icon: 'fill-solid' as const,
},
{
value: DashboardtypesFillModeDTO.gradient,
label: 'Gradient',
icon: 'fill-gradient' as const,
},
];
/**
* Edits the `chartAppearance` slice of a TimeSeries panel spec: line style /
* interpolation, fill mode, point markers, and the connect-null-gaps threshold. Each
* control is gated by its `controls` flag.
*/
function ChartAppearanceSection({
value,
controls,
onChange,
}: SectionEditorProps<'chartAppearance'>): JSX.Element {
// `spanGaps.fillLessThan` is a stringified seconds threshold: empty means "connect
// every gap" (the chart default), a number means "only bridge gaps shorter than this".
const handleSpanGaps = (e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
onChange({
...value,
spanGaps: raw === '' ? undefined : { ...value?.spanGaps, fillLessThan: raw },
});
};
return (
<>
{controls.lineStyle && (
<div className={styles.field}>
<Typography.Text>Line style</Typography.Text>
<ConfigSegmented
testId="panel-editor-v2-line-style"
value={value?.lineStyle}
items={LINE_STYLE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, lineStyle: next as DashboardtypesLineStyleDTO })
}
/>
</div>
)}
{controls.lineInterpolation && (
<div className={styles.field}>
<Typography.Text>Line interpolation</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-line-interpolation"
placeholder="Select interpolation…"
value={value?.lineInterpolation}
items={LINE_INTERPOLATION_OPTIONS}
onChange={(next): void =>
onChange({
...value,
lineInterpolation: next as DashboardtypesLineInterpolationDTO,
})
}
/>
</div>
)}
{controls.fillMode && (
<div className={styles.field}>
<Typography.Text>Fill mode</Typography.Text>
<ConfigSegmented
testId="panel-editor-v2-fill-mode"
value={value?.fillMode}
items={FILL_MODE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, fillMode: next as DashboardtypesFillModeDTO })
}
/>
</div>
)}
{controls.showPoints && (
<ConfigSwitch
testId="panel-editor-v2-show-points"
title="Show points"
description="Display individual data points on the chart"
value={value?.showPoints ?? false}
onChange={(checked): void => onChange({ ...value, showPoints: checked })}
/>
)}
{controls.spanGaps && (
<div className={styles.field}>
<Typography.Text>Connect gaps shorter than (s)</Typography.Text>
<Input
data-testid="panel-editor-v2-span-gaps"
type="number"
placeholder="All gaps"
value={value?.spanGaps?.fillLessThan ?? ''}
onChange={handleSpanGaps}
/>
</div>
)}
</>
);
}
export default ChartAppearanceSection;

View File

@@ -0,0 +1,140 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DashboardtypesLineStyleDTO } from 'api/generated/services/sigNoz.schemas';
import ChartAppearanceSection from '../ChartAppearanceSection';
// Open the antd Select by clicking its selector, then pick the option by label. The
// line-style and fill-mode controls are ConfigSegmented (buttons), so this helper is
// only used for the line-interpolation ConfigSelect.
async function pickOption(triggerTestId: string, label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(triggerTestId);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
}
const ALL_CONTROLS = {
lineStyle: true,
lineInterpolation: true,
fillMode: true,
showPoints: true,
spanGaps: true,
};
describe('ChartAppearanceSection', () => {
it('renders every control that is enabled', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={ALL_CONTROLS}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-line-style')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-line-interpolation'),
).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-fill-mode')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-show-points')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-span-gaps')).toBeInTheDocument();
});
it('renders only the controls whose flag is set', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ lineStyle: true, fillMode: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-line-style')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-fill-mode')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-line-interpolation'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-show-points'),
).not.toBeInTheDocument();
});
it('writes the chosen fill mode through the segmented control', () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ lineStyle: DashboardtypesLineStyleDTO.solid }}
controls={{ fillMode: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('Gradient'));
expect(onChange).toHaveBeenCalledWith({
lineStyle: 'solid',
fillMode: 'gradient',
});
});
it('writes the chosen line interpolation through the dropdown', async () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={undefined}
controls={{ lineInterpolation: true }}
onChange={onChange}
/>,
);
await pickOption('panel-editor-v2-line-interpolation', 'Spline');
expect(onChange).toHaveBeenCalledWith({ lineInterpolation: 'spline' });
});
it('toggles show points through onChange', () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ showPoints: false }}
controls={{ showPoints: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByTestId('panel-editor-v2-show-points'));
expect(onChange).toHaveBeenCalledWith({ showPoints: true });
});
it('writes a span-gaps threshold and clears it when emptied', () => {
const onChange = jest.fn();
const { rerender } = render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
target: { value: '60' },
});
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '60' },
});
rerender(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '60' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
target: { value: '' },
});
expect(onChange).toHaveBeenLastCalledWith({ spanGaps: undefined });
});
});

View File

@@ -0,0 +1,32 @@
.list {
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid var(--l2-border);
border-radius: 6px;
}
.rowFooter {
display: flex;
align-items: center;
justify-content: space-between;
}
.newTab {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.newTabLabel {
font-size: 12px;
color: var(--text-vanilla-400);
}

View File

@@ -0,0 +1,94 @@
import { Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import styles from './ContextLinksSection.module.scss';
/**
* Edits the panel's context links (`spec.links`): a list of label + URL rows with an
* "open in new tab" toggle, plus add/remove. Atomic section — no per-kind sub-controls.
* URLs may reference dashboard/query variables; that interpolation is resolved at render
* time, so this editor just captures the raw strings.
*/
function ContextLinksSection({
value,
onChange,
}: SectionEditorProps<'contextLinks'>): JSX.Element {
const links = value ?? [];
const updateAt = (index: number, patch: Partial<DashboardLinkDTO>): void =>
onChange(
links.map((link, i) => (i === index ? { ...link, ...patch } : link)),
);
const addLink = (): void =>
onChange([...links, { name: '', url: '', targetBlank: true }]);
const removeAt = (index: number): void =>
onChange(links.filter((_, i) => i !== index));
return (
<div className={styles.list}>
{links.map((link, index) => (
// Links have no stable id on the wire; index is the row identity here.
// eslint-disable-next-line react/no-array-index-key
<div className={styles.row} key={index}>
<Input
data-testid={`context-link-label-${index}`}
placeholder="Label"
value={link.name ?? ''}
onChange={(e): void => updateAt(index, { name: e.target.value })}
/>
<Input
data-testid={`context-link-url-${index}`}
placeholder="https://… or /path?var=$variable"
value={link.url ?? ''}
onChange={(e): void => updateAt(index, { url: e.target.value })}
/>
<div className={styles.rowFooter}>
<div className={styles.newTab}>
<Switch
testId={`context-link-newtab-${index}`}
value={link.targetBlank ?? false}
onChange={(checked: boolean): void =>
updateAt(index, { targetBlank: checked })
}
/>
<Typography.Text className={styles.newTabLabel}>
Open in new tab
</Typography.Text>
</div>
<Button
type="button"
variant="ghost"
color="destructive"
size="icon"
aria-label={`Remove link ${index + 1}`}
data-testid={`context-link-remove-${index}`}
onClick={(): void => removeAt(index)}
>
<Trash2 size={14} />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
data-testid="panel-editor-v2-add-link"
onClick={addLink}
>
Add link
</Button>
</div>
);
}
export default ContextLinksSection;

View File

@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import ContextLinksSection from '../ContextLinksSection';
const LINKS: DashboardLinkDTO[] = [
{ name: 'Docs', url: 'https://signoz.io', targetBlank: true },
];
describe('ContextLinksSection', () => {
it('renders only the add button when there are no links', () => {
render(<ContextLinksSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByTestId('panel-editor-v2-add-link')).toBeInTheDocument();
expect(screen.queryByTestId('context-link-label-0')).not.toBeInTheDocument();
});
it('appends a blank link (open-in-new-tab on) when Add link is clicked', () => {
const onChange = jest.fn();
render(<ContextLinksSection value={[]} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
expect(onChange).toHaveBeenCalledWith([
{ name: '', url: '', targetBlank: true },
]);
});
it('renders existing links and edits a label through onChange', () => {
const onChange = jest.fn();
render(<ContextLinksSection value={LINKS} onChange={onChange} />);
expect(screen.getByTestId('context-link-label-0')).toHaveValue('Docs');
expect(screen.getByTestId('context-link-url-0')).toHaveValue(
'https://signoz.io',
);
fireEvent.change(screen.getByTestId('context-link-label-0'), {
target: { value: 'Runbook' },
});
expect(onChange).toHaveBeenCalledWith([
{ name: 'Runbook', url: 'https://signoz.io', targetBlank: true },
]);
});
it('removes a link through onChange', () => {
const onChange = jest.fn();
render(<ContextLinksSection value={LINKS} onChange={onChange} />);
fireEvent.click(screen.getByTestId('context-link-remove-0'));
expect(onChange).toHaveBeenCalledWith([]);
});
});

View File

@@ -0,0 +1,65 @@
import { Typography } from '@signozhq/ui/typography';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
import styles from './FormattingSection.module.scss';
interface ColumnUnitsProps {
/** Resolved value columns of the panel's current table result. */
columns: TableColumnOption[];
/** Current per-column unit map (`formatting.columnUnits`), keyed by column key. */
value: Record<string, string>;
onChange: (next: Record<string, string>) => void;
}
/**
* Per-column unit picker for Table panels: one unit selector per resolved value
* column, writing `{ [columnKey]: unitId }` keyed by the query identifier (V1
* parity). Clearing a column's unit drops its entry. Until the panel produces
* columns, shows a hint.
*/
function ColumnUnits({
columns,
value,
onChange,
}: ColumnUnitsProps): JSX.Element {
if (columns.length === 0) {
return (
<Typography.Text className={styles.columnUnitsHint}>
Run the panel to set per-column units.
</Typography.Text>
);
}
const setUnit = (columnKey: string, unit: string | undefined): void => {
const next = { ...value };
if (unit) {
next[columnKey] = unit;
} else {
delete next[columnKey];
}
onChange(next);
};
return (
<div className={styles.columnUnits}>
{columns.map((column) => (
<div className={styles.columnField} key={column.key}>
<Typography.Text>{column.label}</Typography.Text>
<YAxisUnitSelector
data-testid={`panel-editor-v2-column-unit-${column.key}`}
placeholder="Select unit"
source={YAxisSource.DASHBOARDS}
value={value[column.key]}
containerClassName={styles.columnUnitSelector}
onChange={(unit): void => setUnit(column.key, unit)}
/>
</div>
))}
</div>
);
}
export default ColumnUnits;

View File

@@ -0,0 +1,37 @@
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.unitSelector {
:global(.ant-select) {
width: 100%;
}
}
// Stacked per-column unit pickers; each column keeps the standard field layout.
.columnUnits {
display: flex;
flex-direction: column;
gap: 12px;
:global(.ant-select) {
width: 100%;
}
}
.columnUnitsHint {
font-size: 12px;
color: var(--l2-foreground);
}
.columnField {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
}
.columnUnitSelector {
flex: 1;
}

View File

@@ -0,0 +1,89 @@
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ColumnUnits from './ColumnUnits';
import styles from './FormattingSection.module.scss';
type FormattingSectionProps = SectionEditorProps<'formatting'> & {
/** Table panel's resolved value columns; required for the column-units editor. */
tableColumns?: TableColumnOption[];
};
// `full` means "show the raw value, no rounding"; the digits round to that many places.
const DECIMAL_OPTIONS: {
value: DashboardtypesPrecisionOptionDTO;
label: string;
}[] = [
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_0, label: '0 decimals' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_1, label: '1 decimal' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_2, label: '2 decimals' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_3, label: '3 decimals' },
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_4, label: '4 decimals' },
{ value: DashboardtypesPrecisionOptionDTO.full, label: 'Full' },
];
/**
* Edits the `formatting` slice of a panel spec (unit + decimal precision). Which
* controls show is driven by the per-kind `controls` flags; the spec slice itself
* is uniform across every kind that declares the Formatting section.
*/
function FormattingSection({
value,
controls,
onChange,
tableColumns = [],
}: FormattingSectionProps): JSX.Element {
return (
<>
{controls.unit && (
<div className={styles.field}>
<Typography.Text>Unit</Typography.Text>
<YAxisUnitSelector
containerClassName={styles.unitSelector}
data-testid="panel-editor-v2-unit"
source={YAxisSource.DASHBOARDS}
value={value?.unit}
onChange={(unit): void => onChange({ ...value, unit })}
/>
</div>
)}
{controls.decimals && (
<div className={styles.field}>
<Typography.Text>Decimals</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-decimals"
placeholder="Select decimals…"
value={value?.decimalPrecision}
items={DECIMAL_OPTIONS}
onChange={(next): void =>
onChange({
...value,
decimalPrecision: next as DashboardtypesPrecisionOptionDTO,
})
}
/>
</div>
)}
{controls.columnUnits && (
<div className={styles.field}>
<Typography.Text>Column units</Typography.Text>
<ColumnUnits
columns={tableColumns}
value={value?.columnUnits ?? {}}
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
/>
</div>
)}
</>
);
}
export default FormattingSection;

View File

@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FormattingSection from '../FormattingSection';
// Open the Decimals select (clicking its antd selector) and pick the option with the
// given visible label.
async function pickDecimal(label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId('panel-editor-v2-decimals');
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
}
describe('FormattingSection', () => {
it('renders Unit and Decimals when both controls are enabled', () => {
render(
<FormattingSection
value={undefined}
controls={{ unit: true, decimals: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-unit')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-decimals')).toBeInTheDocument();
});
it('hides a control when its flag is off', () => {
render(
<FormattingSection
value={undefined}
controls={{ decimals: true }}
onChange={jest.fn()}
/>,
);
expect(screen.queryByTestId('panel-editor-v2-unit')).not.toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-decimals')).toBeInTheDocument();
});
it('writes the chosen decimal precision through onChange', async () => {
const onChange = jest.fn();
render(
<FormattingSection
value={undefined}
controls={{ decimals: true }}
onChange={onChange}
/>,
);
await pickDecimal('Full');
expect(onChange).toHaveBeenCalledWith({ decimalPrecision: 'full' });
});
it('merges the edit into the existing formatting slice', async () => {
const onChange = jest.fn();
render(
<FormattingSection
value={{ unit: 'bytes' }}
controls={{ decimals: true }}
onChange={onChange}
/>,
);
await pickDecimal('2 decimals');
expect(onChange).toHaveBeenCalledWith({
unit: 'bytes',
decimalPrecision: '2',
});
});
});

View File

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

View File

@@ -0,0 +1,73 @@
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import LegendColors from '../../controls/LegendColors/LegendColors';
import type { LegendSeries } from '../../../hooks/useLegendSeries';
import styles from './LegendSection.module.scss';
type LegendSectionProps = SectionEditorProps<'legend'> & {
/** Panel's resolved series, forwarded by SectionSlot for the colors control. */
legendSeries?: LegendSeries[];
};
const POSITION_OPTIONS = [
{
value: DashboardtypesLegendPositionDTO.bottom,
label: 'Bottom',
icon: 'pos-bottom' as const,
},
{
value: DashboardtypesLegendPositionDTO.right,
label: 'Right',
icon: 'pos-right' as const,
},
];
/**
* Edits the `legend` slice of a panel spec: legend position and per-series color
* overrides. The colors control reads the panel's resolved series from context (the
* shared preview query) and writes `customColors` keyed by series label.
*/
function LegendSection({
value,
controls,
onChange,
legendSeries,
}: LegendSectionProps): JSX.Element {
return (
<>
{controls.position && (
<div className={styles.field}>
<Typography.Text>Position</Typography.Text>
<ConfigSegmented
testId="panel-editor-v2-legend-position"
items={POSITION_OPTIONS}
value={value?.position}
onChange={(next): void =>
onChange({
...value,
position: next as DashboardtypesLegendPositionDTO,
})
}
/>
</div>
)}
{controls.colors && (
<div className={styles.field}>
<Typography.Text>Series colors</Typography.Text>
<LegendColors
series={legendSeries ?? []}
value={value?.customColors}
onChange={(customColors): void => onChange({ ...value, customColors })}
/>
</div>
)}
</>
);
}
export default LegendSection;

View File

@@ -0,0 +1,68 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
import LegendSection from '../LegendSection';
describe('LegendSection', () => {
it('renders the position toggle with both options when position is enabled', () => {
render(
<LegendSection
value={undefined}
controls={{ position: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-legend-position'),
).toBeInTheDocument();
expect(screen.getByText('Bottom')).toBeInTheDocument();
expect(screen.getByText('Right')).toBeInTheDocument();
});
it('renders nothing when position is not enabled', () => {
render(
<LegendSection value={undefined} controls={{}} onChange={jest.fn()} />,
);
expect(
screen.queryByTestId('panel-editor-v2-legend-position'),
).not.toBeInTheDocument();
});
it('writes the chosen position through onChange', () => {
const onChange = jest.fn();
render(
<LegendSection
value={{ position: undefined }}
controls={{ position: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('Right'));
expect(onChange).toHaveBeenCalledWith({ position: 'right' });
});
it('preserves other legend fields when changing position', () => {
const onChange = jest.fn();
render(
<LegendSection
value={{
position: DashboardtypesLegendPositionDTO.bottom,
customColors: { a: '#fff' },
}}
controls={{ position: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('Right'));
expect(onChange).toHaveBeenCalledWith({
position: 'right',
customColors: { a: '#fff' },
});
});
});

View File

@@ -0,0 +1,50 @@
import { ChevronDown } from '@signozhq/icons';
import { ColorPicker } from 'antd';
import styles from './ThresholdsSection.module.scss';
interface ThresholdColorSelectProps {
value: string;
testId?: string;
onChange: (hex: string) => void;
}
// Named presets from the SigNoz palette (cherry / amber / forest / robin). They surface
// as quick swatches in the picker; the full picker below covers any custom color.
const PRESETS: { label: string; value: string }[] = [
{ label: 'Red', value: '#F1575F' },
{ label: 'Orange', value: '#F5B225' },
{ label: 'Green', value: '#2BB673' },
{ label: 'Blue', value: '#4E74F8' },
];
/**
* Threshold color control: an antd ColorPicker with the palette presets plus a full
* custom picker, in a single popover (so moving from the trigger into the picker never
* dismisses it). The trigger shows the current swatch and its preset name, or "Custom".
*/
function ThresholdColorSelect({
value,
testId,
onChange,
}: ThresholdColorSelectProps): JSX.Element {
const current = PRESETS.find(
(p) => p.value.toLowerCase() === value?.toLowerCase(),
);
return (
<ColorPicker
value={value}
onChangeComplete={(c): void => onChange(c.toHexString())}
presets={[{ label: 'Defaults', colors: PRESETS.map((p) => p.value) }]}
>
<button type="button" className={styles.colorTrigger} data-testid={testId}>
<span className={styles.dot} style={{ backgroundColor: value }} />
<span className={styles.colorLabel}>{current?.label ?? 'Custom'}</span>
<ChevronDown size={13} />
</button>
</ColorPicker>
);
}
export default ThresholdColorSelect;

View File

@@ -0,0 +1,104 @@
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
// ── View mode: compact summary row ──────────────────────────────────────────
.viewRow {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 4px 0 10px;
border: 1px solid var(--l2-border);
background-color: var(--l2-background);
border-radius: 6px;
}
.viewValue {
flex: none;
font-size: 13px;
font-weight: 500;
color: var(--text-vanilla-100);
}
.viewLabel {
overflow: hidden;
font-size: 12px;
color: var(--text-vanilla-400);
white-space: nowrap;
text-overflow: ellipsis;
}
.spacer {
flex: 1;
}
// ── Edit mode: labelled form ────────────────────────────────────────────────
.editRow {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
background-color: var(--l2-background);
border: 1px solid var(--bg-robin-400);
border-radius: 6px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.fieldLabel {
font-size: 12px;
color: var(--text-vanilla-400);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
// ── Shared ──────────────────────────────────────────────────────────────────
.dot {
width: 12px;
height: 12px;
flex: none;
border-radius: 50%;
}
.colorTrigger {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: transparent;
color: var(--text-vanilla-100);
cursor: pointer;
}
.colorLabel {
flex: 1;
font-size: 13px;
text-align: left;
}
// Match Formatting: make the YAxisUnitSelector fill the row width.
.unitSelector {
:global(.ant-select) {
width: 100%;
}
}
.invalidUnit {
font-size: 11px;
color: var(--bg-cherry-400);
}

View File

@@ -0,0 +1,181 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesComparisonThresholdDTO,
type DashboardtypesTableThresholdDTO,
DashboardtypesThresholdFormatDTO,
type DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
AnyThreshold,
ThresholdVariant,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
import ComparisonThresholdRow from './rows/ComparisonThresholdRow';
import LabelThresholdRow from './rows/LabelThresholdRow';
import TableThresholdRow from './rows/TableThresholdRow';
import styles from './ThresholdsSection.module.scss';
// New thresholds default to red (the first palette preset); the user recolors per rule.
const DEFAULT_THRESHOLD_COLOR = '#F1575F';
// Add-button testId per variant — kept stable so existing E2E/unit selectors hold.
const ADD_TESTID: Record<ThresholdVariant, string> = {
label: 'panel-editor-v2-add-threshold',
comparison: 'panel-editor-v2-add-comparison-threshold',
table: 'panel-editor-v2-add-table-threshold',
};
// Seed for a freshly-added row, in the shape the variant's editor + spec expect.
function defaultThreshold(
variant: ThresholdVariant,
tableColumns: TableColumnOption[],
): AnyThreshold {
switch (variant) {
case 'comparison':
return {
value: 0,
color: DEFAULT_THRESHOLD_COLOR,
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.text,
};
case 'table':
return {
columnName: tableColumns[0]?.key ?? '',
value: 0,
color: DEFAULT_THRESHOLD_COLOR,
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.background,
};
default:
return { value: 0, color: DEFAULT_THRESHOLD_COLOR, label: '' };
}
}
type ThresholdsSectionProps = {
value: AnyThreshold[] | undefined;
/** `variant` picks the row editor + element shape; defaults to `label`. */
controls?: { variant?: ThresholdVariant };
onChange: (next: AnyThreshold[]) => void;
/** Panel formatting unit; scopes each row's unit picker to its category (V1 parity). */
yAxisUnit?: string;
/** Table panel's resolved value columns (table variant only). */
tableColumns?: TableColumnOption[];
};
/**
* Edits the `thresholds` slice for every panel kind. All variants share the same
* list mechanics (one row edits at a time; a freshly-added row opens in edit mode and
* is removed if discarded before saving) and differ only in the row editor, picked by
* `controls.variant`: `label` (TimeSeries/Bar), `comparison` (Number), `table` (Table).
*/
function ThresholdsSection({
value,
controls,
onChange,
yAxisUnit,
tableColumns = [],
}: ThresholdsSectionProps): JSX.Element {
const variant = controls?.variant ?? 'label';
const thresholds = value ?? [];
// Which row is being edited, and whether it was just added (so Discard removes it).
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [unsavedIndex, setUnsavedIndex] = useState<number | null>(null);
const addThreshold = (): void => {
const nextIndex = thresholds.length;
onChange([...thresholds, defaultThreshold(variant, tableColumns)]);
setEditingIndex(nextIndex);
setUnsavedIndex(nextIndex);
};
const saveAt =
(index: number) =>
(next: AnyThreshold): void => {
onChange(thresholds.map((t, i) => (i === index ? next : t)));
setEditingIndex(null);
setUnsavedIndex(null);
};
const removeAt = (index: number): void => {
onChange(thresholds.filter((_, i) => i !== index));
setEditingIndex(null);
setUnsavedIndex(null);
};
const discardAt = (index: number) => (): void => {
// Discarding a row that was never saved removes it; otherwise just exit edit.
if (index === unsavedIndex) {
removeAt(index);
return;
}
setEditingIndex(null);
};
const renderRow = (threshold: AnyThreshold, index: number): JSX.Element => {
// Shared row controls; the threshold value is narrowed per variant at this
// branch boundary — the slice only ever holds the active variant's shape.
const common = {
index,
yAxisUnit,
isEditing: editingIndex === index,
onEdit: (): void => setEditingIndex(index),
onSave: saveAt(index),
onDiscard: discardAt(index),
onRemove: (): void => removeAt(index),
};
if (variant === 'comparison') {
return (
<ComparisonThresholdRow
// eslint-disable-next-line react/no-array-index-key
key={index}
threshold={threshold as DashboardtypesComparisonThresholdDTO}
{...common}
/>
);
}
if (variant === 'table') {
return (
<TableThresholdRow
// eslint-disable-next-line react/no-array-index-key
key={index}
threshold={threshold as DashboardtypesTableThresholdDTO}
tableColumns={tableColumns}
{...common}
/>
);
}
return (
<LabelThresholdRow
// eslint-disable-next-line react/no-array-index-key
key={index}
threshold={threshold as DashboardtypesThresholdWithLabelDTO}
{...common}
/>
);
};
return (
<div className={styles.list}>
{thresholds.map(renderRow)}
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
data-testid={ADD_TESTID[variant]}
onClick={addThreshold}
>
Add threshold
</Button>
</div>
);
}
export default ThresholdsSection;

View File

@@ -0,0 +1,198 @@
import { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import UnifiedThresholdsSection from '../ThresholdsSection';
// The comparison editor is the unified ThresholdsSection in its `comparison` variant;
// this wrapper pins the variant so the suite reads as the comparison editor's spec.
function ComparisonThresholdsSection(props: {
value: DashboardtypesComparisonThresholdDTO[] | undefined;
onChange: (next: DashboardtypesComparisonThresholdDTO[]) => void;
yAxisUnit?: string;
}): JSX.Element {
return (
<UnifiedThresholdsSection
value={props.value}
onChange={props.onChange as (next: AnyThreshold[]) => void}
yAxisUnit={props.yAxisUnit}
controls={{ variant: 'comparison' }}
/>
);
}
const THRESHOLDS: DashboardtypesComparisonThresholdDTO[] = [
{
value: 80,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'percent',
format: DashboardtypesThresholdFormatDTO.background,
},
];
// Stateful harness for flows that depend on the value updating (add/discard).
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<DashboardtypesComparisonThresholdDTO[]>([]);
return (
<ComparisonThresholdsSection
value={value}
onChange={setValue}
yAxisUnit={yAxisUnit}
/>
);
}
describe('ComparisonThresholdsSection', () => {
it('renders only the add button when there are no thresholds', () => {
render(
<ComparisonThresholdsSection value={undefined} onChange={jest.fn()} />,
);
expect(
screen.getByTestId('panel-editor-v2-add-comparison-threshold'),
).toBeInTheDocument();
expect(
screen.queryByTestId('comparison-threshold-edit-0'),
).not.toBeInTheDocument();
});
it('shows an existing threshold in view mode (no form until Edit)', () => {
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={jest.fn()} />,
);
expect(screen.getByTestId('comparison-threshold-edit-0')).toBeInTheDocument();
// Operator symbol + value render in the summary.
expect(screen.getByText(/> 80/)).toBeInTheDocument();
// The editable fields are hidden until the row is edited.
expect(
screen.queryByTestId('comparison-threshold-value-0'),
).not.toBeInTheDocument();
});
it('formats the view-mode value through its unit (e.g. currency symbol)', () => {
render(
<ComparisonThresholdsSection
value={[
{
value: 3100,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.below,
unit: 'currencyUSD',
},
]}
onChange={jest.fn()}
/>,
);
const row = screen.getByTestId('comparison-threshold-edit-0').closest('div');
// Unit-aware: shows the currency symbol, never the raw unit id.
expect(row).toHaveTextContent('$');
expect(row).not.toHaveTextContent('currencyUSD');
});
it('edits a threshold value and commits it on Save', () => {
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
fireEvent.click(screen.getByTestId('comparison-threshold-edit-0'));
expect(screen.getByTestId('comparison-threshold-value-0')).toHaveValue(80);
fireEvent.change(screen.getByTestId('comparison-threshold-value-0'), {
target: { value: '90' },
});
fireEvent.click(screen.getByTestId('comparison-threshold-save-0'));
expect(onChange).toHaveBeenCalledWith([
{
value: 90,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'percent',
format: DashboardtypesThresholdFormatDTO.background,
},
]);
});
it('does not commit edits when Discard is clicked', () => {
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
fireEvent.click(screen.getByTestId('comparison-threshold-edit-0'));
fireEvent.change(screen.getByTestId('comparison-threshold-value-0'), {
target: { value: '90' },
});
fireEvent.click(screen.getByTestId('comparison-threshold-discard-0'));
expect(onChange).not.toHaveBeenCalled();
// Back to view mode.
expect(
screen.queryByTestId('comparison-threshold-value-0'),
).not.toBeInTheDocument();
expect(screen.getByTestId('comparison-threshold-edit-0')).toBeInTheDocument();
});
it('removes a threshold from view mode', () => {
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
fireEvent.click(screen.getByTestId('comparison-threshold-remove-0'));
expect(onChange).toHaveBeenCalledWith([]);
});
it('adds a threshold that opens in edit mode, and discards it away', () => {
render(<Harness />);
fireEvent.click(
screen.getByTestId('panel-editor-v2-add-comparison-threshold'),
);
// New row opens in edit mode.
expect(
screen.getByTestId('comparison-threshold-value-0'),
).toBeInTheDocument();
fireEvent.click(screen.getByTestId('comparison-threshold-discard-0'));
// Discarding a never-saved row removes it entirely.
expect(
screen.queryByTestId('comparison-threshold-value-0'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('comparison-threshold-edit-0'),
).not.toBeInTheDocument();
});
it('flags a threshold unit in a different category than the y-axis unit', () => {
render(
<ComparisonThresholdsSection
value={[
{
value: 80,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'ms',
},
]}
yAxisUnit="bytes"
onChange={jest.fn()}
/>,
);
fireEvent.click(screen.getByTestId('comparison-threshold-edit-0'));
expect(
screen.getByTestId('comparison-threshold-unit-invalid-0'),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,122 @@
import { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ThresholdsSection from '../ThresholdsSection';
const THRESHOLDS: DashboardtypesThresholdWithLabelDTO[] = [
{ value: 80, color: '#F5B225', label: 'High', unit: 'percent' },
];
// Stateful harness for flows that depend on the value updating (add/discard). No
// `controls` is passed, exercising the default `label` variant.
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<AnyThreshold[]>([]);
return (
<ThresholdsSection value={value} onChange={setValue} yAxisUnit={yAxisUnit} />
);
}
describe('ThresholdsSection', () => {
it('renders only the add button when there are no thresholds', () => {
render(<ThresholdsSection value={undefined} onChange={jest.fn()} />);
expect(
screen.getByTestId('panel-editor-v2-add-threshold'),
).toBeInTheDocument();
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
});
it('shows an existing threshold in view mode (no form until Edit)', () => {
render(<ThresholdsSection value={THRESHOLDS} onChange={jest.fn()} />);
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
expect(screen.getByText('High')).toBeInTheDocument();
// The editable fields are hidden until the row is edited.
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
});
it('edits a threshold value and commits it on Save', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(screen.getByTestId('threshold-value-0')).toHaveValue(80);
fireEvent.change(screen.getByTestId('threshold-value-0'), {
target: { value: '90' },
});
fireEvent.click(screen.getByTestId('threshold-save-0'));
expect(onChange).toHaveBeenCalledWith([
{ value: 90, color: '#F5B225', label: 'High', unit: 'percent' },
]);
});
it('does not commit edits when Discard is clicked', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
fireEvent.click(screen.getByTestId('threshold-edit-0'));
fireEvent.change(screen.getByTestId('threshold-value-0'), {
target: { value: '90' },
});
fireEvent.click(screen.getByTestId('threshold-discard-0'));
expect(onChange).not.toHaveBeenCalled();
// Back to view mode.
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
});
it('removes a threshold from view mode', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
fireEvent.click(screen.getByTestId('threshold-remove-0'));
expect(onChange).toHaveBeenCalledWith([]);
});
it('adds a threshold that opens in edit mode, and discards it away', () => {
render(<Harness />);
fireEvent.click(screen.getByTestId('panel-editor-v2-add-threshold'));
// New row opens in edit mode.
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('threshold-discard-0'));
// Discarding a never-saved row removes it entirely.
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
});
it('flags a threshold unit in a different category than the y-axis unit', () => {
render(
<ThresholdsSection
value={[{ value: 80, color: '#F5B225', label: '', unit: 'ms' }]}
yAxisUnit="bytes"
onChange={jest.fn()}
/>,
);
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(screen.getByTestId('threshold-unit-invalid-0')).toBeInTheDocument();
});
it('does not flag a threshold unit in the same category as the y-axis unit', () => {
render(
<ThresholdsSection
value={[{ value: 80, color: '#F5B225', label: '', unit: 'ms' }]}
yAxisUnit="s"
onChange={jest.fn()}
/>,
);
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(
screen.queryByTestId('threshold-unit-invalid-0'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,117 @@
import {
type DashboardtypesComparisonOperatorDTO,
type DashboardtypesComparisonThresholdDTO,
type DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import { formatPanelValue } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/formatPanelValue';
import {
FORMAT_OPTIONS,
OPERATOR_OPTIONS,
OPERATOR_SYMBOL,
} from '../thresholdOptions';
import ThresholdColorField from './shared/ThresholdColorField';
import ThresholdRowShell from './shared/ThresholdRowShell';
import ThresholdSelectField from './shared/ThresholdSelectField';
import ThresholdUnitField from './shared/ThresholdUnitField';
import { useThresholdDraft } from './shared/useThresholdDraft';
import ThresholdValueField from './shared/ThresholdValueField';
import styles from '../ThresholdsSection.module.scss';
interface ComparisonThresholdRowProps {
index: number;
threshold: DashboardtypesComparisonThresholdDTO;
/** Panel formatting unit — scopes the unit picker to its category (V1 parity). */
yAxisUnit?: string;
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesComparisonThresholdDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
/**
* Comparison threshold (Number): value crosses an operator → recolor. Edit form is
* condition (operator), value, unit, color, display format.
*/
function ComparisonThresholdRow({
index,
threshold,
yAxisUnit,
isEditing,
onEdit,
onSave,
onDiscard,
onRemove,
}: ComparisonThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
const symbol = threshold.operator ? OPERATOR_SYMBOL[threshold.operator] : '';
const summary = (
<span className={styles.viewValue}>
{symbol} {formatPanelValue(threshold.value, threshold.unit)}
</span>
);
return (
<ThresholdRowShell
index={index}
testIdPrefix="comparison-threshold"
color={threshold.color}
isEditing={isEditing}
summary={summary}
onEdit={onEdit}
onSave={(): void => onSave(draft)}
onDiscard={onDiscard}
onRemove={onRemove}
>
<ThresholdSelectField
label="If value is"
testId={`comparison-threshold-operator-${index}`}
placeholder="Select condition"
value={draft.operator}
items={OPERATOR_OPTIONS}
onChange={(operator): void =>
setDraft((d) => ({
...d,
operator: operator as DashboardtypesComparisonOperatorDTO,
}))
}
/>
<ThresholdValueField
testId={`comparison-threshold-value-${index}`}
value={draft.value}
onChange={setValue}
/>
<ThresholdUnitField
testId={`comparison-threshold-unit-${index}`}
invalidTestId={`comparison-threshold-unit-invalid-${index}`}
value={draft.unit}
scopeUnit={yAxisUnit}
scopeLabel="y-axis unit"
onChange={(unit): void => setDraft((d) => ({ ...d, unit }))}
/>
<ThresholdColorField
testId={`comparison-threshold-color-${index}`}
value={draft.color}
onChange={(color): void => setDraft((d) => ({ ...d, color }))}
/>
<ThresholdSelectField
label="Display"
testId={`comparison-threshold-format-${index}`}
placeholder="Select display"
value={draft.format}
items={FORMAT_OPTIONS}
onChange={(format): void =>
setDraft((d) => ({
...d,
format: format as DashboardtypesThresholdFormatDTO,
}))
}
/>
</ThresholdRowShell>
);
}
export default ComparisonThresholdRow;

View File

@@ -0,0 +1,96 @@
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
import { formatPanelValue } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/formatPanelValue';
import ThresholdColorField from './shared/ThresholdColorField';
import ThresholdRowShell from './shared/ThresholdRowShell';
import ThresholdUnitField from './shared/ThresholdUnitField';
import { useThresholdDraft } from './shared/useThresholdDraft';
import ThresholdValueField from './shared/ThresholdValueField';
import styles from '../ThresholdsSection.module.scss';
interface LabelThresholdRowProps {
index: number;
threshold: DashboardtypesThresholdWithLabelDTO;
/** Panel formatting unit — scopes the unit picker to its category (V1 parity). */
yAxisUnit?: string;
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesThresholdWithLabelDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
/**
* Value + color + label threshold (TimeSeries / Bar): a line drawn on the chart. Edit
* form is color, value, unit, label.
*/
function LabelThresholdRow({
index,
threshold,
yAxisUnit,
isEditing,
onEdit,
onSave,
onDiscard,
onRemove,
}: LabelThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
const summary = (
<>
<span className={styles.viewValue}>
{formatPanelValue(threshold.value, threshold.unit)}
</span>
{threshold.label && (
<span className={styles.viewLabel}>{threshold.label}</span>
)}
</>
);
return (
<ThresholdRowShell
index={index}
testIdPrefix="threshold"
color={threshold.color}
isEditing={isEditing}
summary={summary}
onEdit={onEdit}
onSave={(): void => onSave(draft)}
onDiscard={onDiscard}
onRemove={onRemove}
>
<ThresholdColorField
testId={`threshold-color-${index}`}
value={draft.color}
onChange={(color): void => setDraft((d) => ({ ...d, color }))}
/>
<ThresholdValueField
testId={`threshold-value-${index}`}
value={draft.value}
onChange={setValue}
/>
<ThresholdUnitField
testId={`threshold-unit-${index}`}
invalidTestId={`threshold-unit-invalid-${index}`}
value={draft.unit}
scopeUnit={yAxisUnit}
scopeLabel="y-axis unit"
onChange={(unit): void => setDraft((d) => ({ ...d, unit }))}
/>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Label</Typography.Text>
<Input
data-testid={`threshold-label-${index}`}
placeholder="Optional"
value={draft.label ?? ''}
onChange={(e): void => setDraft((d) => ({ ...d, label: e.target.value }))}
/>
</div>
</ThresholdRowShell>
);
}
export default LabelThresholdRow;

View File

@@ -0,0 +1,141 @@
import {
type DashboardtypesComparisonOperatorDTO,
type DashboardtypesTableThresholdDTO,
type DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import { formatPanelValue } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/formatPanelValue';
import type { TableColumnOption } from '../../../../hooks/useTableColumns';
import {
FORMAT_OPTIONS,
OPERATOR_OPTIONS,
OPERATOR_SYMBOL,
} from '../thresholdOptions';
import ThresholdColorField from './shared/ThresholdColorField';
import ThresholdRowShell from './shared/ThresholdRowShell';
import ThresholdSelectField from './shared/ThresholdSelectField';
import ThresholdUnitField from './shared/ThresholdUnitField';
import { useThresholdDraft } from './shared/useThresholdDraft';
import ThresholdValueField from './shared/ThresholdValueField';
import styles from '../ThresholdsSection.module.scss';
interface TableThresholdRowProps {
index: number;
threshold: DashboardtypesTableThresholdDTO;
/** Resolved value columns (with their configured units); the rule targets one. */
tableColumns: TableColumnOption[];
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesTableThresholdDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
/**
* Per-column comparison threshold (Table): value in a column crosses an operator →
* recolor that column's cells. Edit form is column, condition (operator), value, unit,
* color, display format. The unit picker scopes to the selected column's unit (Table
* panels have no single panel-wide unit — V1 parity).
*/
function TableThresholdRow({
index,
threshold,
tableColumns,
isEditing,
onEdit,
onSave,
onDiscard,
onRemove,
}: TableThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
// Stored columnName is the query key; resolve its label + configured unit.
const columnUnit = tableColumns.find((c) => c.key === draft.columnName)?.unit;
const columnLabel =
tableColumns.find((c) => c.key === threshold.columnName)?.label ??
threshold.columnName;
const columnItems = tableColumns.map((column) => ({
value: column.key,
label: column.label,
}));
const symbol = threshold.operator ? OPERATOR_SYMBOL[threshold.operator] : '';
const summary = (
<>
<span className={styles.viewLabel}>{columnLabel}</span>
<span className={styles.viewValue}>
{symbol} {formatPanelValue(threshold.value, threshold.unit)}
</span>
</>
);
return (
<ThresholdRowShell
index={index}
testIdPrefix="table-threshold"
color={threshold.color}
isEditing={isEditing}
summary={summary}
onEdit={onEdit}
onSave={(): void => onSave(draft)}
onDiscard={onDiscard}
onRemove={onRemove}
>
<ThresholdSelectField
label="Column"
testId={`table-threshold-column-${index}`}
placeholder="Select column"
value={draft.columnName || undefined}
items={columnItems}
onChange={(columnName): void => setDraft((d) => ({ ...d, columnName }))}
/>
<ThresholdSelectField
label="If value is"
testId={`table-threshold-operator-${index}`}
placeholder="Select condition"
value={draft.operator}
items={OPERATOR_OPTIONS}
onChange={(operator): void =>
setDraft((d) => ({
...d,
operator: operator as DashboardtypesComparisonOperatorDTO,
}))
}
/>
<ThresholdValueField
testId={`table-threshold-value-${index}`}
value={draft.value}
onChange={setValue}
/>
<ThresholdUnitField
testId={`table-threshold-unit-${index}`}
invalidTestId={`table-threshold-unit-invalid-${index}`}
value={draft.unit}
scopeUnit={columnUnit}
scopeLabel="column unit"
onChange={(unit): void => setDraft((d) => ({ ...d, unit }))}
/>
<ThresholdColorField
testId={`table-threshold-color-${index}`}
value={draft.color}
onChange={(color): void => setDraft((d) => ({ ...d, color }))}
/>
<ThresholdSelectField
label="Display"
testId={`table-threshold-format-${index}`}
placeholder="Select display"
value={draft.format}
items={FORMAT_OPTIONS}
onChange={(format): void =>
setDraft((d) => ({
...d,
format: format as DashboardtypesThresholdFormatDTO,
}))
}
/>
</ThresholdRowShell>
);
}
export default TableThresholdRow;

View File

@@ -0,0 +1,27 @@
import { Typography } from '@signozhq/ui/typography';
import ThresholdColorSelect from '../../ThresholdColorSelect';
import styles from '../../ThresholdsSection.module.scss';
interface ThresholdColorFieldProps {
testId: string;
value: string;
onChange: (hex: string) => void;
}
/** Labelled color picker, shared by every threshold variant. */
function ThresholdColorField({
testId,
value,
onChange,
}: ThresholdColorFieldProps): JSX.Element {
return (
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Color</Typography.Text>
<ThresholdColorSelect value={value} testId={testId} onChange={onChange} />
</div>
);
}
export default ThresholdColorField;

View File

@@ -0,0 +1,103 @@
import type { ReactNode } from 'react';
import { Check, Pencil, Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from '../../ThresholdsSection.module.scss';
interface ThresholdRowShellProps {
index: number;
/** testId prefix per variant: `threshold` | `comparison-threshold` | `table-threshold`. */
testIdPrefix: string;
/** Swatch color shown in view mode. */
color: string;
isEditing: boolean;
/** Compact view-mode summary, rendered between the color dot and the actions. */
summary: ReactNode;
/** Edit-mode fields. */
children: ReactNode;
onEdit: () => void;
onSave: () => void;
onDiscard: () => void;
onRemove: () => void;
}
/**
* Shared chrome for a threshold row's V1-style view/edit modes: the view summary with
* Edit/Delete, and the edit form's Discard/Save actions. Each variant supplies its own
* `summary` and field `children`; everything else (layout, buttons, testIds) is shared.
*/
function ThresholdRowShell({
index,
testIdPrefix,
color,
isEditing,
summary,
children,
onEdit,
onSave,
onDiscard,
onRemove,
}: ThresholdRowShellProps): JSX.Element {
if (!isEditing) {
return (
<div className={styles.viewRow}>
<span className={styles.dot} style={{ backgroundColor: color }} />
{summary}
<div className={styles.spacer} />
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
aria-label={`Edit threshold ${index + 1}`}
data-testid={`${testIdPrefix}-edit-${index}`}
onClick={onEdit}
>
<Pencil size={14} />
</Button>
<Button
type="button"
variant="ghost"
color="destructive"
size="icon"
aria-label={`Remove threshold ${index + 1}`}
data-testid={`${testIdPrefix}-remove-${index}`}
onClick={onRemove}
>
<Trash2 size={14} />
</Button>
</div>
);
}
return (
<div className={styles.editRow}>
{children}
<div className={styles.actions}>
<Button
type="button"
variant="outlined"
color="secondary"
prefix={<X size={14} />}
data-testid={`${testIdPrefix}-discard-${index}`}
onClick={onDiscard}
>
Discard
</Button>
<Button
type="button"
variant="solid"
color="primary"
prefix={<Check size={14} />}
data-testid={`${testIdPrefix}-save-${index}`}
onClick={onSave}
>
Save
</Button>
</div>
</div>
);
}
export default ThresholdRowShell;

View File

@@ -0,0 +1,44 @@
import { Typography } from '@signozhq/ui/typography';
import ConfigSelect, {
type ConfigSelectItem,
} from '../../../../controls/ConfigSelect/ConfigSelect';
import styles from '../../ThresholdsSection.module.scss';
interface ThresholdSelectFieldProps {
label: string;
testId: string;
placeholder?: string;
value: string | undefined;
items: ConfigSelectItem[];
onChange: (value: string) => void;
}
/**
* Labelled single-select, shared by the threshold variants' enum fields
* (operator / display format / column).
*/
function ThresholdSelectField({
label,
testId,
placeholder,
value,
items,
onChange,
}: ThresholdSelectFieldProps): JSX.Element {
return (
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>{label}</Typography.Text>
<ConfigSelect
testId={testId}
placeholder={placeholder}
value={value}
items={items}
onChange={onChange}
/>
</div>
);
}
export default ThresholdSelectField;

View File

@@ -0,0 +1,57 @@
import { Typography } from '@signozhq/ui/typography';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import {
isThresholdUnitIncompatible,
thresholdUnitCategories,
} from '../../thresholdUnitCategories';
import styles from '../../ThresholdsSection.module.scss';
interface ThresholdUnitFieldProps {
testId: string;
invalidTestId: string;
value: string | undefined;
/** Unit whose category scopes the picker (panel y-axis unit, or the column's unit). */
scopeUnit: string | undefined;
/** How the scope reads in the mismatch message, e.g. "y-axis unit" / "column unit". */
scopeLabel: string;
onChange: (unit: string) => void;
}
/**
* Labelled unit picker, scoped to `scopeUnit`'s category (V1 parity) and flagging a
* threshold unit that resolves to a different category. Shared by every variant; only
* the scope source and its wording differ.
*/
function ThresholdUnitField({
testId,
invalidTestId,
value,
scopeUnit,
scopeLabel,
onChange,
}: ThresholdUnitFieldProps): JSX.Element {
return (
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Unit</Typography.Text>
<YAxisUnitSelector
containerClassName={styles.unitSelector}
data-testid={testId}
placeholder="Select unit"
source={YAxisSource.DASHBOARDS}
categoriesOverride={thresholdUnitCategories(scopeUnit)}
value={value}
onChange={onChange}
/>
{isThresholdUnitIncompatible(value, scopeUnit) && (
<Typography.Text className={styles.invalidUnit} data-testid={invalidTestId}>
Threshold unit ({value}) is not valid with the {scopeLabel} ({scopeUnit})
</Typography.Text>
)}
</div>
);
}
export default ThresholdUnitField;

View File

@@ -0,0 +1,33 @@
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import styles from '../../ThresholdsSection.module.scss';
interface ThresholdValueFieldProps {
testId: string;
value: number;
/** Receives the raw input string; the draft hook parses it. */
onChange: (raw: string) => void;
}
/** Labelled numeric "Value" input, shared by every threshold variant. */
function ThresholdValueField({
testId,
value,
onChange,
}: ThresholdValueFieldProps): JSX.Element {
return (
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Value</Typography.Text>
<Input
data-testid={testId}
type="number"
placeholder="Value"
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
</div>
);
}
export default ThresholdValueField;

View File

@@ -0,0 +1,34 @@
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
interface ThresholdDraft<T> {
draft: T;
setDraft: Dispatch<SetStateAction<T>>;
/** Parse a raw input string into `value`, ignoring transient non-numeric input. */
setValue: (raw: string) => void;
}
/**
* Local draft for a threshold row, shared by every variant. Snapshots the saved
* threshold on each entry into edit mode (so Discard simply drops the draft and the
* next edit starts clean) and exposes the numeric `value` setter all variants use.
*/
export function useThresholdDraft<T extends { value: number }>(
threshold: T,
isEditing: boolean,
): ThresholdDraft<T> {
const [draft, setDraft] = useState<T>(threshold);
useEffect(() => {
if (isEditing) {
setDraft(threshold);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- snapshot only on edit entry
}, [isEditing]);
const setValue = (raw: string): void => {
const next = Number(raw);
setDraft((d) => ({ ...d, value: Number.isNaN(next) ? d.value : next }));
};
return { draft, setDraft, setValue };
}

View File

@@ -0,0 +1,47 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
// Comparison operators offered in the "If value is" condition picker. Labels pair a
// word with its math symbol so the dropdown reads clearly while the view row can show
// the compact symbol (OPERATOR_SYMBOL below).
export const OPERATOR_OPTIONS: ConfigSelectItem[] = [
{ value: DashboardtypesComparisonOperatorDTO.above, label: 'Above (>)' },
{
value: DashboardtypesComparisonOperatorDTO.above_or_equal,
label: 'Above or equal (≥)',
},
{ value: DashboardtypesComparisonOperatorDTO.below, label: 'Below (<)' },
{
value: DashboardtypesComparisonOperatorDTO.below_or_equal,
label: 'Below or equal (≤)',
},
{ value: DashboardtypesComparisonOperatorDTO.equal, label: 'Equal (=)' },
{
value: DashboardtypesComparisonOperatorDTO.not_equal,
label: 'Not equal (≠)',
},
];
// Compact symbol shown in the collapsed (view-mode) summary row.
export const OPERATOR_SYMBOL: Record<
DashboardtypesComparisonOperatorDTO,
string
> = {
[DashboardtypesComparisonOperatorDTO.above]: '>',
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '≥',
[DashboardtypesComparisonOperatorDTO.below]: '<',
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '≤',
[DashboardtypesComparisonOperatorDTO.equal]: '=',
[DashboardtypesComparisonOperatorDTO.not_equal]: '≠',
};
// How the threshold recolors the panel: just the number ("text") or the whole tile
// ("background").
export const FORMAT_OPTIONS: ConfigSelectItem[] = [
{ value: DashboardtypesThresholdFormatDTO.background, label: 'Background' },
{ value: DashboardtypesThresholdFormatDTO.text, label: 'Text' },
];

View File

@@ -0,0 +1,54 @@
import {
type YAxisCategory,
YAxisSource,
} from 'components/YAxisUnitSelector/types';
import {
getYAxisCategories,
mapMetricUnitToUniversalUnit,
} from 'components/YAxisUnitSelector/utils';
// The unit category (Time, Data, …) a unit belongs to, or undefined if unrecognized.
function categoryForUnit(unit: string): YAxisCategory | undefined {
const universal = mapMetricUnitToUniversalUnit(unit);
return getYAxisCategories(YAxisSource.DASHBOARDS).find((c) =>
c.units.some((u) => u.id === universal),
);
}
/**
* Restricts the threshold unit picker to the panel's y-axis unit family, mirroring V1:
* a threshold is only meaningfully comparable to the axis when it shares its category
* (e.g. an `ms` axis → only Time units). Returns the single matching category, or
* `undefined` (all categories) when the panel has no unit set or it can't be mapped.
*/
export function thresholdUnitCategories(
yAxisUnit: string | undefined,
): YAxisCategory[] | undefined {
if (!yAxisUnit) {
return undefined;
}
const category = categoryForUnit(yAxisUnit);
return category ? [category] : undefined;
}
/**
* True when a threshold's unit belongs to a different category than the panel's y-axis
* unit (so the values can't be compared) — drives the V1-style mismatch message. Only
* flags when both units are set and resolve to distinct categories (e.g. a stale `ms`
* threshold left over after the axis unit was changed to bytes).
*/
export function isThresholdUnitIncompatible(
thresholdUnit: string | undefined,
yAxisUnit: string | undefined,
): boolean {
if (!thresholdUnit || !yAxisUnit) {
return false;
}
const thresholdCategory = categoryForUnit(thresholdUnit);
const axisCategory = categoryForUnit(yAxisUnit);
return Boolean(
thresholdCategory &&
axisCategory &&
thresholdCategory.name !== axisCategory.name,
);
}

View File

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

View File

@@ -0,0 +1,67 @@
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
import styles from './VisualizationSection.module.scss';
/**
* Edits the `visualization` slice: the per-panel time preference (all kinds), bar
* stacking (`stackedBarChart`, Bar only), and gap filling (`fillSpans`, TimeSeries
* only). Each control is gated by its `controls` flag, so a kind only renders — and only
* writes — the visualization fields its spec actually supports.
*/
function VisualizationSection({
value,
controls,
onChange,
}: SectionEditorProps<'visualization'>): JSX.Element {
return (
<>
{controls.timePreference && (
<div className={styles.field}>
<Typography.Text>Panel time preference</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-time-preference"
placeholder="Select time scope…"
value={value?.timePreference}
items={TIME_PREFERENCE_OPTIONS}
onChange={(next): void =>
onChange({
...value,
timePreference: next as DashboardtypesTimePreferenceDTO,
})
}
/>
</div>
)}
{controls.stacking && (
<ConfigSwitch
testId="panel-editor-v2-stacked-bar-chart"
title="Stack series"
description="Stack bars from all series on top of each other"
value={value?.stackedBarChart ?? false}
onChange={(checked): void =>
onChange({ ...value, stackedBarChart: checked })
}
/>
)}
{controls.fillSpans && (
<ConfigSwitch
testId="panel-editor-v2-fill-spans"
title="Fill gaps"
description="Fill gaps in data with 0 for continuity"
value={value?.fillSpans ?? false}
onChange={(checked): void => onChange({ ...value, fillSpans: checked })}
/>
)}
</>
);
}
export default VisualizationSection;

View File

@@ -0,0 +1,104 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import VisualizationSection from '../VisualizationSection';
// Open the antd Select by clicking its selector, then pick the option by label.
async function pickOption(triggerTestId: string, label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(triggerTestId);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
}
describe('VisualizationSection', () => {
it('renders every control that is enabled', () => {
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true, stacking: true, fillSpans: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-time-preference'),
).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-stacked-bar-chart'),
).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-fill-spans')).toBeInTheDocument();
});
it('renders only the controls whose flag is set', () => {
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-time-preference'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-stacked-bar-chart'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-fill-spans'),
).not.toBeInTheDocument();
});
it('writes the chosen time preference through the dropdown', async () => {
const onChange = jest.fn();
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true }}
onChange={onChange}
/>,
);
await pickOption('panel-editor-v2-time-preference', 'Last 1 hr');
expect(onChange).toHaveBeenCalledWith({ timePreference: 'last_1_hr' });
});
it('toggles bar stacking through onChange, preserving other fields', () => {
const onChange = jest.fn();
render(
<VisualizationSection
value={{
timePreference: DashboardtypesTimePreferenceDTO.global_time,
stackedBarChart: false,
}}
controls={{ stacking: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByTestId('panel-editor-v2-stacked-bar-chart'));
expect(onChange).toHaveBeenCalledWith({
timePreference: 'global_time',
stackedBarChart: true,
});
});
it('toggles fill spans through onChange', () => {
const onChange = jest.fn();
render(
<VisualizationSection
value={{ fillSpans: false }}
controls={{ fillSpans: true }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByTestId('panel-editor-v2-fill-spans'));
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
});
});

View File

@@ -0,0 +1,18 @@
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
// Per-panel time scope. "Global Time" follows the dashboard's time picker; the rest pin
// the panel to a fixed relative window regardless of the dashboard range (V1 parity).
export const TIME_PREFERENCE_OPTIONS: ConfigSelectItem[] = [
{ value: DashboardtypesTimePreferenceDTO.global_time, label: 'Global Time' },
{ value: DashboardtypesTimePreferenceDTO.last_5_min, label: 'Last 5 min' },
{ value: DashboardtypesTimePreferenceDTO.last_15_min, label: 'Last 15 min' },
{ value: DashboardtypesTimePreferenceDTO.last_30_min, label: 'Last 30 min' },
{ value: DashboardtypesTimePreferenceDTO.last_1_hr, label: 'Last 1 hr' },
{ value: DashboardtypesTimePreferenceDTO.last_6_hr, label: 'Last 6 hr' },
{ value: DashboardtypesTimePreferenceDTO.last_1_day, label: 'Last 1 day' },
{ value: DashboardtypesTimePreferenceDTO.last_3_days, label: 'Last 3 days' },
{ value: DashboardtypesTimePreferenceDTO.last_1_week, label: 'Last 1 week' },
{ value: DashboardtypesTimePreferenceDTO.last_1_month, label: 'Last 1 month' },
];

View File

@@ -0,0 +1,22 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
background-color: var(--l1-background-60);
border-bottom: 1px solid var(--l1-border);
}
.title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -0,0 +1,84 @@
import { useCallback, useState } from 'react';
import { SolidAlertTriangle, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import styles from './Header.module.scss';
interface HeaderProps {
isDirty: boolean;
isSaving: boolean;
onSave: () => void;
onClose: () => void;
}
function Header({
isDirty,
isSaving,
onSave,
onClose,
}: HeaderProps): JSX.Element {
const [isDiscardOpen, setIsDiscardOpen] = useState(false);
// Closing with unsaved edits prompts for confirmation; a pristine panel closes
// straight away.
const handleCloseClick = useCallback((): void => {
if (isDirty) {
setIsDiscardOpen(true);
} else {
onClose();
}
}, [isDirty, onClose]);
return (
<div className={styles.header}>
<div className={styles.title}>
<Button
variant="ghost"
color="secondary"
size="icon"
suffix={<X size={14} />}
data-testid="panel-editor-v2-close"
onClick={handleCloseClick}
/>
<Divider type="vertical" />
<Typography.Text>Configure panel</Typography.Text>
</div>
<div className={styles.actions}>
<Button
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled={!isDirty || isSaving}
loading={isSaving}
onClick={onSave}
>
Save changes
</Button>
</div>
<ConfirmDialog
open={isDiscardOpen}
onOpenChange={(next): void => {
if (!next) {
setIsDiscardOpen(false);
}
}}
title="Discard changes?"
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
confirmText="Discard"
confirmColor="destructive"
cancelText="Keep editing"
onConfirm={onClose}
onCancel={(): void => setIsDiscardOpen(false)}
data-testid="panel-editor-v2-discard-modal"
>
<Typography>Your unsaved edits to this panel will be lost.</Typography>
</ConfirmDialog>
</div>
);
}
export default Header;

View File

@@ -0,0 +1,28 @@
// Full-page editor: fills the route's content area as a header-over-split
// column (the editor is its own page now, not a modal overlay).
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--l1-background);
}
.left {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.right {
display: flex;
}
.handle {
background: var(--l1-border);
&:hover {
background: var(--l2-border);
}
}

View File

@@ -0,0 +1,45 @@
@use '../../../../../styles/scrollbar' as *;
.container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: auto;
background-color: var(--l1-background);
@include custom-scrollbar;
}
.scrollArea {
padding: 12px;
}
.tabsContainer {
width: 100%;
:global(.ant-tabs-tab) {
background-color: var(--l2-background) !important;
border-color: var(--l2-border) !important;
}
:global(.ant-tabs-tab-active) {
background-color: var(--l1-background) !important;
}
:global(.ant-tabs-nav) {
&::before {
border-color: var(--l2-border);
}
}
}
.queryTypeTab {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.runQueryBtnContainer {
padding: 4px 0 8px 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 1rem;
}

View File

@@ -0,0 +1,170 @@
import {
type KeyboardEvent,
type ReactNode,
useCallback,
useMemo,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Atom, Terminal } from '@signozhq/icons';
import { Tabs } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import TextToolTip from 'components/TextToolTip';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ClickHouseQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse';
import PromQLQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL';
import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import styles from './PanelEditorQueryBuilder.module.scss';
interface PanelEditorQueryBuilderProps {
panelType: PANEL_TYPES;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
isLoadingQueries: boolean;
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
onStageRunQuery: () => void;
/** Abort the in-flight preview fetch (the button's cancel action). */
onCancelQuery: () => void;
/** Optional content pinned below the builder (e.g. the List columns editor). */
footer?: ReactNode;
}
/**
* Query builder for the V2 panel editor's left pane — the queryType tabs
* (Query Builder / ClickHouse / PromQL) over the shared `QueryBuilderV2` and the
* V1 ClickHouse/PromQL containers, plus the Stage & Run button. All of these
* read/write the global `QueryBuilderProvider`; `usePanelEditorQuerySync` owns
* seeding the provider from the panel and pushing Stage-&-Run results back into
* the editor draft, so this component is purely the builder UI.
*/
function PanelEditorQueryBuilder({
panelType,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
footer,
}: PanelEditorQueryBuilderProps): JSX.Element {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
const handleQueryCategoryChange = useCallback(
(queryType: string): void => {
redirectWithQueryBuilderData({
...currentQuery,
queryType: queryType as EQueryType,
});
},
[currentQuery, redirectWithQueryBuilderData],
);
// ⌘↵ / Ctrl+↵ stages and runs the query while a query-builder field is
// focused. The global keyboard-hotkeys provider deliberately ignores keydowns
// originating in inputs / the query editor, so this is handled locally. Uses
// the capture phase so it fires even for fields that stop the event from
// bubbling (e.g. the filter search, CodeMirror) — the container sees the
// keydown on the way down to the focused field.
const handleKeyDownCapture = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
onStageRunQuery();
}
},
[onStageRunQuery],
);
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => ({ stepInterval: { isHidden: false, isDisabled: false } }),
[],
);
const items = useMemo(() => {
const supportedQueryTypes = PANEL_TYPE_TO_QUERY_TYPES[panelType] || [];
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
icon: <Atom size={14} />,
label: 'Query Builder',
component: (
<div className="query-builder-v2-container">
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
showTraceOperator={panelType !== PANEL_TYPES.LIST}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
queryComponents={{}}
signalSourceChangeEnabled
savePreviousQuery
/>
</div>
),
},
[EQueryType.CLICKHOUSE]: {
icon: <Terminal size={14} />,
label: 'ClickHouse Query',
component: <ClickHouseQueryContainer />,
},
[EQueryType.PROM]: {
icon: (
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
/>
),
label: 'PromQL',
component: <PromQLQueryContainer />,
},
};
return supportedQueryTypes.map((queryType) => ({
key: queryType,
label: (
<div className={styles.queryTypeTab}>
{queryTypeComponents[queryType].icon}
<Typography>{queryTypeComponents[queryType].label}</Typography>
</div>
),
children: queryTypeComponents[queryType].component,
}));
}, [panelType, filterConfigs, isDarkMode]);
return (
<div
className={styles.container}
data-testid="panel-editor-v2-query-builder"
onKeyDownCapture={handleKeyDownCapture}
role="presentation"
>
<div className={styles.scrollArea}>
<Tabs
type="card"
className={styles.tabsContainer}
activeKey={currentQuery.queryType}
onChange={handleQueryCategoryChange}
tabBarExtraContent={
<span className={styles.runQueryBtnContainer}>
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
<RunQueryBtn
className="run-query-dashboard-btn"
label="Stage & Run Query"
onStageRunQuery={onStageRunQuery}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={onCancelQuery}
/>
</span>
}
items={items}
/>
</div>
{footer}
</div>
);
}
export default PanelEditorQueryBuilder;

View File

@@ -0,0 +1,59 @@
.preview {
display: flex;
flex-direction: column;
height: 100%;
gap: 16px;
padding: 24px;
background-image: radial-gradient(var(--l2-border) 1px, transparent 0);
background-size: 20px 20px;
border-bottom: 1px solid var(--l1-border);
}
.header {
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
}
.queryType {
display: inline-flex;
padding: 4px 8px 4px 6px;
align-items: center;
gap: 6px;
border-radius: 4px;
background: var(--l3-background);
backdrop-filter: blur(6px);
width: fit-content;
}
.container {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
}
.surface {
flex: 1;
min-width: 0;
min-height: 0;
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
display: flex;
background: var(--l2-background);
padding: 8px;
}
.state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--l2-forground);
font-size: 13px;
text-align: center;
}

View File

@@ -0,0 +1,79 @@
import { Spin } from 'antd';
import { Loader, Spline } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { EQueryType } from 'types/common/dashboard';
import styles from './PreviewPane.module.scss';
interface PreviewPaneProps {
panelId: string;
panel: DashboardtypesPanelDTO;
/** Resolved definition for the panel kind; undefined when the kind is unsupported. */
panelDef: RenderablePanelDefinition | undefined;
data: PanelQueryData;
isLoading: boolean;
error: Error | null;
/** Drag-to-zoom on a time-axis chart → updates the (URL-synced) time window. */
onDragSelect: (start: number, end: number) => void;
}
/**
* Live preview for the panel editor. Presentational: the draft panel renders through the
* same registry the dashboard grid uses (`panelDef.Renderer`), so the preview is the
* production renderer — only `panelMode` differs (DASHBOARD_EDIT). The query result is
* owned by the editor root (`usePanelQuery`) and passed in, so the same result is shared
* with the config pane.
*/
function PreviewPane({
panelId,
panel,
panelDef,
data,
isLoading,
error,
onDragSelect,
}: PreviewPaneProps): JSX.Element {
return (
<div className={styles.preview}>
<div className={styles.header}>
<div className={styles.queryType}>
<Spline size={14} />
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
</div>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<div className={styles.container}>
<div className={styles.surface}>
{/* eslint-disable-next-line no-nested-ternary -- 3-way branch on render state */}
{!panelDef ? (
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
This panel type is not yet supported in V2.
</div>
) : isLoading && !data.response ? (
<div className={styles.state} data-testid="panel-editor-v2-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
</div>
) : (
<panelDef.Renderer
panelId={panelId}
panel={panel}
data={data}
isLoading={isLoading}
error={error}
panelMode={PanelMode.DASHBOARD_EDIT}
enableDrillDown={false}
onDragSelect={onDragSelect}
/>
)}
</div>
</div>
</div>
);
}
export default PreviewPane;

View File

@@ -0,0 +1,102 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { flattenTimeSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { useLegendSeries } from '../useLegendSeries';
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: jest.fn() }));
jest.mock('lib/getLabelName', () => jest.fn(() => 'base'));
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
generateColor: jest.fn((label: string) => `color:${label}`),
}));
jest.mock('constants/theme', () => ({
themeColors: { chartcolors: ['dark'], lightModeColor: ['light'] },
}));
jest.mock(
'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries',
() => ({ getBuilderQueries: jest.fn(() => []) }),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel',
() => ({ resolveSeriesLabelV5: jest.fn() }),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData',
() => ({
flattenTimeSeries: jest.fn(),
getTimeSeriesResults: jest.fn(() => []),
}),
);
const mockUseIsDarkMode = useIsDarkMode as unknown as jest.Mock;
const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
const mockGenerateColor = generateColor as unknown as jest.Mock;
const PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
// Each flattened series carries the label resolveSeriesLabelV5 should report.
function seriesWithLabels(labels: string[]): { __label: string }[] {
return labels.map((__label) => ({ __label }));
}
describe('useLegendSeries', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsDarkMode.mockReturnValue(true);
mockResolveLabel.mockImplementation((s: { __label: string }) => s.__label);
});
it('is empty when there are no series', () => {
mockFlatten.mockReturnValue([]);
const { result } = renderHook(() => useLegendSeries(PANEL, DATA));
expect(result.current).toStrictEqual([]);
});
it('maps each series to a { label, defaultColor } pair', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a', 'b']));
const { result } = renderHook(() => useLegendSeries(PANEL, DATA));
expect(result.current).toStrictEqual([
{ label: 'a', defaultColor: 'color:a' },
{ label: 'b', defaultColor: 'color:b' },
]);
});
it('dedupes by label, keeping first-seen order', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a', 'b', 'a', 'c']));
const { result } = renderHook(() => useLegendSeries(PANEL, DATA));
expect(result.current.map((s) => s.label)).toStrictEqual(['a', 'b', 'c']);
// The duplicate 'a' must not generate a second color.
expect(
mockGenerateColor.mock.calls.filter(([label]) => label === 'a'),
).toHaveLength(1);
});
it('skips series that resolve to an empty label', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['', 'a', '']));
const { result } = renderHook(() => useLegendSeries(PANEL, DATA));
expect(result.current).toStrictEqual([
{ label: 'a', defaultColor: 'color:a' },
]);
});
it('uses the dark palette in dark mode and the light palette otherwise', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a']));
const dark = renderHook(() => useLegendSeries(PANEL, DATA));
expect(mockGenerateColor).toHaveBeenLastCalledWith('a', ['dark']);
dark.unmount();
mockUseIsDarkMode.mockReturnValue(false);
renderHook(() => useLegendSeries(PANEL, DATA));
expect(mockGenerateColor).toHaveBeenLastCalledWith('a', ['light']);
});
});

View File

@@ -0,0 +1,93 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelEditorDraft } from '../usePanelEditorDraft';
function panel(name = 'CPU', description = 'usage'): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name, description },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('usePanelEditorDraft', () => {
it('exposes the panel spec and starts clean', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
expect(result.current.spec).toBe(result.current.draft.spec);
expect(result.current.spec.display?.name).toBe('CPU');
expect(result.current.isSpecDirty).toBe(false);
});
it('flags dirty and writes through on a display (title) edit via setSpec', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() =>
result.current.setSpec({
...result.current.spec,
display: { ...result.current.spec.display, name: 'Memory' },
}),
);
expect(result.current.isSpecDirty).toBe(true);
expect(result.current.draft.spec?.display?.name).toBe('Memory');
});
it('flags dirty on a plugin-spec (non-display) edit', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() =>
result.current.setSpec({
...result.current.spec,
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { formatting: { unit: 'bytes' } },
},
} as typeof result.current.spec),
);
expect(result.current.isSpecDirty).toBe(true);
expect(
(
result.current.draft.spec?.plugin?.spec as {
formatting?: { unit?: string };
}
)?.formatting?.unit,
).toBe('bytes');
});
it('does not flag spec-dirty when only spec.queries changes (owned by the builder)', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() =>
result.current.setSpec({
...result.current.spec,
queries: [{ id: 'committed-by-builder' }],
} as unknown as typeof result.current.spec),
);
expect(result.current.isSpecDirty).toBe(false);
});
it('reset restores the spec and clears dirty after an edit', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() =>
result.current.setSpec({
...result.current.spec,
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { formatting: { unit: 'ms' } },
},
} as typeof result.current.spec),
);
act(() => result.current.reset());
expect(result.current.isSpecDirty).toBe(false);
expect(result.current.spec.display?.name).toBe('CPU');
});
});

View File

@@ -0,0 +1,331 @@
import { renderHook } from '@testing-library/react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getIsQueryModified } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { fromPerses, toPerses } from '../../../queryV5/persesQueryAdapters';
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
useShareBuilderUrl: jest.fn(),
}));
jest.mock('container/NewWidget/utils', () => ({
getIsQueryModified: jest.fn(),
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
fromPerses: jest.fn(),
toPerses: jest.fn(),
}));
// commitQuery's no-op guard compares queries at the envelope level; with the
// adapters mocked, unwrap identity-style so the opaque fixtures stay distinct
// (CONVERTED vs SAVED) and the commit decisions are what's under test.
jest.mock('../../../queryV5/buildQueryRangeRequest', () => ({
toQueryEnvelopes: jest.fn((queries: unknown) => queries),
}));
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockUseShareBuilderUrl = useShareBuilderUrl as unknown as jest.Mock;
const mockGetIsQueryModified = getIsQueryModified as unknown as jest.Mock;
const mockFromPerses = fromPerses as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
// Opaque fixtures — the adapters are mocked, so only identity matters here.
const SAVED_QUERIES = [{ id: 'saved' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const CONVERTED_QUERIES = [{ id: 'converted' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const SEED_V1 = { id: 'seed', queryType: 'builder' } as unknown as Query;
const STAGED_V1 = { id: 'staged', queryType: 'builder' } as unknown as Query;
function makeDraft(
queries = SAVED_QUERIES,
kind = 'signoz/TimeSeriesPanel',
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'Panel' },
plugin: { kind, spec: {} },
queries,
},
} as unknown as DashboardtypesPanelDTO;
}
function builderState(
overrides: Partial<{
currentQuery: Query;
stagedQuery: Query | null;
handleRunQuery: jest.Mock;
}> = {},
): {
currentQuery: Query;
stagedQuery: Query | null;
handleRunQuery: jest.Mock;
} {
return {
currentQuery: { id: 'current', queryType: 'builder' } as unknown as Query,
stagedQuery: STAGED_V1,
handleRunQuery: jest.fn(),
...overrides,
};
}
describe('usePanelEditorQuerySync', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFromPerses.mockReturnValue(SEED_V1);
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
mockGetIsQueryModified.mockReturnValue(false);
mockUseQueryBuilder.mockReturnValue(builderState());
});
function setup(
opts: {
draft?: DashboardtypesPanelDTO;
setSpec?: jest.Mock;
refetch?: jest.Mock;
} = {},
): {
result: {
current: {
runQuery: () => void;
isQueryDirty: boolean;
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
};
};
setSpec: jest.Mock;
refetch: jest.Mock;
rerender: () => void;
} {
const setSpec = opts.setSpec ?? jest.fn();
const refetch = opts.refetch ?? jest.fn();
const draft = opts.draft ?? makeDraft();
const { result, rerender } = renderHook(() =>
usePanelEditorQuerySync({
draft,
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
refetch,
}),
);
return { result, setSpec, refetch, rerender };
}
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
setup();
expect(mockFromPerses).toHaveBeenCalledWith(
SAVED_QUERIES,
PANEL_TYPES.TIME_SERIES,
);
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
defaultValue: SEED_V1,
forceReset: true,
});
});
it('does not touch the draft on mount for an unedited panel', () => {
const { setSpec, refetch } = setup();
// Mount runs the type-change effect once; an unedited query must no-op.
expect(setSpec).not.toHaveBeenCalled();
expect(refetch).not.toHaveBeenCalled();
});
it('compares the live query against the saved query (seed), not the staged query', () => {
const currentQuery = { id: 'current', queryType: 'builder' } as Query;
mockUseQueryBuilder.mockReturnValue(builderState({ currentQuery }));
const { result } = setup();
result.current.runQuery();
// Baseline is the saved seed — a stale staged/URL query must not be the
// reference, or a real datasource switch would read as "unchanged".
expect(mockGetIsQueryModified).toHaveBeenCalledWith(currentQuery, SEED_V1);
});
describe('runQuery', () => {
it('stages the query (handleRunQuery)', () => {
const handleRunQuery = jest.fn();
mockUseQueryBuilder.mockReturnValue(builderState({ handleRunQuery }));
const { result } = setup();
result.current.runQuery();
expect(handleRunQuery).toHaveBeenCalledTimes(1);
});
it('commits a modified query into the draft and does not force a refetch', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result, setSpec, refetch } = setup();
result.current.runQuery();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
expect(refetch).not.toHaveBeenCalled();
});
it('forces a refetch and leaves the draft alone when the query is unchanged', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result, setSpec, refetch } = setup();
result.current.runQuery();
expect(setSpec).not.toHaveBeenCalled();
expect(refetch).toHaveBeenCalledTimes(1);
});
it('commits a datasource switch even when the staged query is stale (no revert to saved)', () => {
// A stale staged query (e.g. URL-restored after refresh) must not be used
// as the baseline; the switch is detected against the saved seed and the
// live query is committed so the preview fetches it.
mockUseQueryBuilder.mockReturnValue(builderState({ stagedQuery: null }));
mockGetIsQueryModified.mockReturnValue(true);
const { result, setSpec, refetch } = setup();
result.current.runQuery();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
expect(refetch).not.toHaveBeenCalled();
});
});
describe('query-type switch', () => {
it('commits the active query when the query type changes', () => {
const state = builderState({
currentQuery: { id: 'a', queryType: 'builder' } as Query,
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Switch query type → the effect should commit.
state.currentQuery = { id: 'b', queryType: 'promql' } as Query;
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
it('does not commit when the active query type is unchanged', () => {
const state = builderState({
currentQuery: { id: 'a', queryType: 'builder' } as Query,
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Same query type, different object → effect must not re-fire.
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
rerender();
expect(setSpec).not.toHaveBeenCalled();
});
});
describe('datasource switch', () => {
const withSource = (id: string, dataSource: string): Query =>
({
id,
queryType: 'builder',
builder: { queryData: [{ dataSource }] },
}) as unknown as Query;
it('commits the active query when a query datasource changes', () => {
const state = builderState({ currentQuery: withSource('a', 'logs') });
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Switch datasource logs → traces → the effect should commit (→ refetch).
state.currentQuery = withSource('b', 'traces');
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
it('does not commit when the datasource is unchanged', () => {
const state = builderState({ currentQuery: withSource('a', 'logs') });
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Same datasource, different object → effect must not re-fire.
state.currentQuery = withSource('b', 'logs');
rerender();
expect(setSpec).not.toHaveBeenCalled();
});
});
describe('query dirty + save', () => {
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
// Baseline is the builder's own normalized staged query — immune to the
// raw-seed vs builder-normalized serialization drift.
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
expect.anything(),
STAGED_V1,
);
expect(result.current.isQueryDirty).toBe(true);
});
it('is not query-dirty when the live query matches the baseline', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
expect(result.current.isQueryDirty).toBe(false);
});
it('buildSaveSpec bakes the live query in when dirty', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
...spec,
queries: CONVERTED_QUERIES,
});
});
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toBe(spec);
});
});
});

View File

@@ -0,0 +1,82 @@
import { renderHook } from '@testing-library/react';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
} from 'api/generated/services/dashboard';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelEditorSave } from '../usePanelEditorSave';
const mockInvalidateQueries = jest.fn();
jest.mock('react-query', () => ({
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
invalidateQueries: mockInvalidateQueries,
}),
}));
jest.mock('api/generated/services/dashboard', () => ({
usePatchDashboardV2: jest.fn(),
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
}));
const mockUsePatch = usePatchDashboardV2 as unknown as jest.Mock;
const mockGetQueryKey = getGetDashboardV2QueryKey as unknown as jest.Mock;
describe('usePanelEditorSave', () => {
const mutateAsync = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
jest.clearAllMocks();
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: false,
error: null,
});
});
it('emits an add patch replacing the whole panel spec and invalidates the dashboard query', async () => {
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
);
const spec = {
display: { name: 'New title', description: 'desc' },
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { formatting: { unit: 'bytes' } },
},
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
await result.current.save(spec);
expect(mutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'dash-1' },
data: [
{
op: 'add',
path: '/spec/panels/panel-9/spec',
value: spec,
},
],
});
expect(mockGetQueryKey).toHaveBeenCalledWith({ id: 'dash-1' });
expect(mockInvalidateQueries).toHaveBeenCalledWith([
'/api/v2/dashboards/dash-1',
]);
});
it('surfaces the mutation loading state as isSaving', () => {
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: true,
error: null,
});
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
);
expect(result.current.isSaving).toBe(true);
});
});

View File

@@ -0,0 +1,107 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { useTableColumns } from '../useTableColumns';
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables',
() => ({ prepareScalarTables: jest.fn() }),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData',
() => ({ getScalarResults: jest.fn(() => []) }),
);
const mockPrepareScalarTables = prepareScalarTables as unknown as jest.Mock;
const DATA = {
response: undefined,
legendMap: {},
requestPayload: undefined,
} as unknown as PanelQueryData;
function tablePanel(
columnUnits: Record<string, string> = {},
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'T' },
plugin: { kind: 'signoz/TablePanel', spec: { formatting: { columnUnits } } },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
function tableWith(columns: unknown[]): void {
mockPrepareScalarTables.mockReturnValue([{ columns, rows: [] }]);
}
describe('useTableColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns [] for a non-table panel kind', () => {
const panel = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const { result } = renderHook(() => useTableColumns(panel, DATA));
expect(result.current).toStrictEqual([]);
expect(mockPrepareScalarTables).not.toHaveBeenCalled();
});
it('returns [] when there is no scalar table with columns', () => {
mockPrepareScalarTables.mockReturnValue([{ columns: [], rows: [] }]);
const { result } = renderHook(() => useTableColumns(tablePanel(), DATA));
expect(result.current).toStrictEqual([]);
});
it('keeps only value columns and maps them to key/label', () => {
tableWith([
{ id: 'service.name', name: 'service.name', isValueColumn: false },
{ id: 'A', name: 'p99', isValueColumn: true },
]);
const { result } = renderHook(() => useTableColumns(tablePanel(), DATA));
expect(result.current).toStrictEqual([
{ key: 'A', label: 'p99', unit: undefined },
]);
});
it('falls back to the column name when the column has no id', () => {
tableWith([{ name: 'count', isValueColumn: true }]);
const { result } = renderHook(() => useTableColumns(tablePanel(), DATA));
expect(result.current[0].key).toBe('count');
});
it('resolves a column unit by its key', () => {
tableWith([{ id: 'A', name: 'p99', isValueColumn: true }]);
const { result } = renderHook(() =>
useTableColumns(tablePanel({ A: 'ms' }), DATA),
);
expect(result.current[0].unit).toBe('ms');
});
it('falls back to the base query name for a multi-aggregation column key', () => {
tableWith([{ id: 'A.p99', name: 'p99', isValueColumn: true }]);
const { result } = renderHook(() =>
useTableColumns(tablePanel({ A: 'bytes' }), DATA),
);
expect(result.current[0].unit).toBe('bytes');
});
});

View File

@@ -0,0 +1,59 @@
import { useMemo } from 'react';
import { themeColors } from 'constants/theme';
import { useIsDarkMode } from 'hooks/useDarkMode';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import {
flattenTimeSeries,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
export interface LegendSeries {
/** Resolved display label — the key `legend.customColors` is indexed by. */
label: string;
/** The series' auto-assigned color, shown when no override is set. */
defaultColor: string;
}
/**
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs, using the
* exact label resolution the time-series renderer applies (`flattenTimeSeries` →
* `resolveSeriesLabelV5`) and the same `generateColor` default. The legend-colors control
* keys overrides by these labels, so they must match what the chart draws. Deduplicated,
* order-preserving; empty until data arrives or for kinds without flat time-series data.
*/
export function useLegendSeries(
panel: DashboardtypesPanelDTO,
data: PanelQueryData,
): LegendSeries[] {
const isDarkMode = useIsDarkMode();
return useMemo(() => {
const palette = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;
const series = flattenTimeSeries(
getTimeSeriesResults(data?.response),
data.legendMap,
);
const builderQueries = getBuilderQueries(panel?.spec?.queries);
const byLabel = new Map<string, string>();
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
if (label && !byLabel.has(label)) {
byLabel.set(label, generateColor(label, palette));
}
});
return Array.from(byLabel, ([label, defaultColor]) => ({
label,
defaultColor,
}));
}, [panel.spec.queries, data.response, data.legendMap, isDarkMode]);
}

View File

@@ -0,0 +1,56 @@
import { useCallback, useMemo, useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { isEqual } from 'lodash-es';
import type { PanelEditorDraftApi } from '../types';
/**
* Owns the editable draft of a single panel. Seeded once from the loaded panel
* (`useState` initializer), then mutated locally until the user saves. Keeping
* the draft in the perses `DashboardtypesPanelDTO` shape lets the preview pane
* render it through the same renderer registry the dashboard uses, and lets the
* save hook patch it without any conversion.
*
* Everything the config pane edits — title/description, the per-kind plugin spec
* (formatting, axes, …), legend colors, context links — flows through the single
* `spec`/`setSpec` pair (the ConfigPane registry lens), so there is one editing path.
*/
export function usePanelEditorDraft(
initialPanel: DashboardtypesPanelDTO,
): PanelEditorDraftApi {
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
const setSpec = useCallback((next: DashboardtypesPanelSpecDTO): void => {
setDraft((prev) => ({ ...prev, spec: next }));
}, []);
const reset = useCallback((): void => {
setDraft(initialPanel);
}, [initialPanel]);
// Deep compare, ignoring `spec.queries`: the live query is owned by the shared
// query builder and re-serialized into the draft purely as a preview cache, so
// its representation drifts (builder-filled defaults, regenerated ids, wrapper
// kind) without a real edit. Query dirtiness is tracked separately against the
// builder; here we only flag divergence in display + plugin spec slices
// (formatting, axes, thresholds, links, list columns, …).
const isSpecDirty = useMemo(
() =>
!isEqual(
{ ...draft, spec: { ...draft.spec, queries: null } },
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
),
[draft, initialPanel],
);
return {
draft,
spec: draft.spec,
setSpec,
isSpecDirty,
reset,
};
}

View File

@@ -0,0 +1,161 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getIsQueryModified } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { isEqual } from 'lodash-es';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
import { fromPerses, toPerses } from '../../queryV5/persesQueryAdapters';
interface UsePanelEditorQuerySyncArgs {
draft: DashboardtypesPanelDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Re-fetch the preview when the query is unchanged (Stage & Run on a no-op). */
refetch: () => void;
}
interface UsePanelEditorQuerySyncApi {
/** Run the current query (Stage & Run / ⌘↵). */
runQuery: () => void;
/**
* True when the live builder query differs from the saved query. Compared
* builder-normalized vs builder-normalized (against the builder's own baseline),
* so re-serialization noise never reads as an edit.
*/
isQueryDirty: boolean;
/**
* Bake the live query into a spec for saving — so unstaged edits still persist.
* Returns the spec untouched when the query is unchanged from saved.
*/
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
}
/**
* Bridges the shared query builder (global `QueryBuilderProvider`, URL-synced) and
* the V2 editor draft: seeds the builder from the saved panel, then commits the
* active query into `draft.spec.queries` (what the preview fetches) on a query-type
* or datasource switch and on Stage & Run.
*/
export function usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
// Saved queries, captured once: seed the builder and serve as the restore target.
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
const savedQueries = useMemo(() => draft.spec?.queries ?? [], []);
const seedQuery = useMemo(
() => fromPerses(savedQueries, panelType),
[savedQueries, panelType],
);
// Open the builder from the SAVED panel, discarding any stale URL query left by
// a prior edit/refresh — otherwise the QB shows the URL query while the preview
// keeps fetching the saved one, and the dirty baseline gets captured from the URL
// (so switching back to that datasource reads as "unchanged" and never commits).
// Force-reset on the first render only; after that the URL syncs normally.
const isInitialRenderRef = useRef(true);
useShareBuilderUrl({
defaultValue: seedQuery,
forceReset: isInitialRenderRef.current,
});
useEffect(() => {
isInitialRenderRef.current = false;
}, []);
// Commit the live query into the draft (what the preview fetches). The dirty
// check compares against the SAVED query (`seedQuery`), not the URL-synced
// staged query — a staged query can carry stale state across a refresh, which
// would make a real datasource switch read as "unchanged" and silently revert.
// Unchanged from saved → restore the saved queries (don't dirty the draft);
// changed → commit the live query. Returns whether the draft changed.
const commitQuery = useCallback(
(query: Query): boolean => {
const next = getIsQueryModified(query, seedQuery)
? toPerses(query, panelType)
: savedQueries;
// No-op guard at the V5 envelope level: an unchanged query re-serialized
// to a different but equivalent wrapper (a bare `signoz/BuilderQuery` vs a
// `signoz/CompositeQuery`) unwraps to the same envelopes — comparing the
// wrappers structurally would falsely dirty the draft on Stage & Run.
const current = draft.spec?.queries ?? [];
if (isEqual(toQueryEnvelopes(next), toQueryEnvelopes(current))) {
return false;
}
setSpec({ ...draft.spec, queries: next });
return true;
},
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
);
// Latest query/commit, read by the structural-change effect without re-subscribing.
const commitRef = useRef(commitQuery);
commitRef.current = commitQuery;
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
// Re-commit on a query-type or datasource switch so the preview refetches the
// structurally-changed query. Skip mount: the draft already holds the saved
// queries and the builder is being force-reset to them.
const dataSourceSignature = useMemo(
() =>
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
[currentQuery.builder],
);
const didMountRef = useRef(false);
useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
return;
}
commitRef.current(queryRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
}, [currentQuery.queryType, dataSourceSignature]);
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so the same query
// can be re-run.
const runQuery = useCallback((): void => {
handleRunQuery();
if (!commitQuery(currentQuery)) {
refetch();
}
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
// Dirty baseline for the query: the builder's OWN normalized form of the saved
// query — the first non-null `stagedQuery` the builder produces after the mount
// force-reset. Capturing it (instead of the raw `seedQuery`) makes the dirty
// check builder-normalized vs builder-normalized, immune to the serialization
// drift that otherwise reads an untouched query as modified. Held in state (not
// a ref) so capturing it re-renders and `isQueryDirty` recomputes; captured once
// and never moved by Stage & Run, so it stays anchored to the saved query.
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
useEffect(() => {
if (queryBaseline === null && stagedQuery) {
setQueryBaseline(stagedQuery);
}
}, [queryBaseline, stagedQuery]);
const isQueryDirty =
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
const buildSaveSpec = useCallback(
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
isQueryDirty
? { ...spec, queries: toPerses(currentQuery, panelType) }
: spec,
[isQueryDirty, currentQuery, panelType],
);
return { runQuery, isQueryDirty, buildSaveSpec };
}

View File

@@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
} from 'api/generated/services/dashboard';
import {
type DashboardtypesJSONPatchOperationDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesPatchOpDTO,
} from 'api/generated/services/sigNoz.schemas';
interface UsePanelEditorSaveArgs {
dashboardId: string;
panelId: string;
}
interface UsePanelEditorSaveApi {
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
isSaving: boolean;
error: Error | null;
}
/**
* Persists panel edits for the V2 editor via RFC-6902 JSON Patch.
*
* Replaces the whole panel spec in one `add` op against `/spec/panels/{panelId}/spec`
* with the editor's draft spec — so every edit the config pane makes (display,
* formatting/axes/legend/chart-appearance under `plugin.spec`, `legend.customColors`,
* context links) is persisted, not just the title/description. `add` doubles as
* create-or-replace, so panels that loaded without a sub-object are handled without a
* separate existence check. The draft carries `queries` unchanged until the V2 query
* builder lands, so replacing the whole spec is safe.
*/
export function usePanelEditorSave({
dashboardId,
panelId,
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
const queryClient = useQueryClient();
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
const save = useCallback(
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
{
op: DashboardtypesPatchOpDTO.add,
path: `/spec/panels/${panelId}/spec`,
value: spec,
},
];
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
await queryClient.invalidateQueries(
getGetDashboardV2QueryKey({ id: dashboardId }),
);
},
[dashboardId, panelId, mutateAsync, queryClient],
);
return { save, isSaving: isLoading, error: (error as Error) ?? null };
}

View File

@@ -0,0 +1,88 @@
import { useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
export interface TableColumnOption {
/**
* Key the column's unit / threshold is stored under — the query identifier
* (`queryName`, or `queryName.expression` for multi-aggregation queries). Matches
* `PanelTableColumn.id` and the rendered column's `dataIndex` (V1 parity: column
* units and thresholds are keyed by the query, not the display name).
*/
key: string;
/** Display label shown in the editor — the resolved column name. */
label: string;
/**
* The column's configured unit (`formatting.columnUnits[key]`), if any. The
* per-column threshold editor scopes its unit picker to this unit's category
* (V1 parity), since Table panels have no single panel-wide unit.
*/
unit?: string;
}
// Resolve a column's unit by its key, falling back to the base query name (the legacy
// `queryName.expression` → `queryName` syntax) — mirrors the renderer's getColumnUnit.
function resolveColumnUnit(
key: string,
columnUnits: Record<string, string>,
): string | undefined {
if (columnUnits[key]) {
return columnUnits[key];
}
if (key.includes('.')) {
const baseQuery = key.split('.')[0];
if (columnUnits[baseQuery]) {
return columnUnits[baseQuery];
}
}
return undefined;
}
/**
* Resolves a Table panel's value (aggregation) columns into `{ key, label }`
* options, so the table-only config editors (column units, per-column thresholds)
* store the query-keyed value the renderer looks up by while showing the readable
* column name. Empty for non-table kinds or before data arrives.
*/
export function useTableColumns(
panel: DashboardtypesPanelDTO,
data: PanelQueryData,
): TableColumnOption[] {
return useMemo(() => {
if (panel?.spec?.plugin?.kind !== 'signoz/TablePanel') {
return [];
}
const table = prepareScalarTables({
results: getScalarResults(data?.response),
legendMap: data?.legendMap ?? {},
requestPayload: data?.requestPayload,
}).find((candidate) => candidate.columns.length > 0);
if (!table) {
return [];
}
const columnUnits =
(
panel?.spec?.plugin?.spec as
| { formatting?: { columnUnits?: Record<string, string> | null } }
| undefined
)?.formatting?.columnUnits ?? {};
return table.columns
.filter((column) => column.isValueColumn)
.map((column) => {
const key = column.id || column.name;
return {
key,
label: column.name,
unit: resolveColumnUnit(key, columnUnits),
};
});
}, [
panel.spec.plugin.kind,
panel.spec.plugin.spec,
data?.response,
data.legendMap,
data?.requestPayload,
]);
}

View File

@@ -0,0 +1,182 @@
import { useCallback } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import layoutStorage from './layoutStorage';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useTableColumns } from './hooks/useTableColumns';
import styles from './PanelEditor.module.scss';
interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
onSaved: () => void;
}
/**
* V2 panel editor page body. Rendered by the `DASHBOARD_PANEL_EDITOR` route
* (`PanelEditorPage`) as a full page — a resizable split holds the live preview
* + query builder on the left and the configuration pane on the right. Owns the
* draft state and the save round-trip.
*/
function PanelEditorContainer({
dashboardId,
panelId,
panel,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
// Panel kind → V1 panel type (drives the query builder + preview).
const fullKind = draft.spec?.plugin?.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
PANEL_TYPES.TIME_SERIES;
// One shared query result for the whole editor: the preview renders it and the config
// pane derives the panel's series from it (e.g. for the legend-colors control).
const panelDef = getPanelDefinition(draft.spec?.plugin?.kind);
const { data, isLoading, isFetching, error, cancelQuery, refetch } =
usePanelQuery({
panel: draft,
panelId,
enabled: !!panelDef,
});
// Seed the shared query builder from the draft and expose the Stage-&-Run
// action (writes the query into the draft → preview re-fetches, or forces a
// re-fetch when unchanged).
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
});
// Dirty = an edited config slice (display/plugin spec) OR an edited query. The
// two are tracked independently so query re-serialization never false-dirties.
const isDirty = isSpecDirty || isQueryDirty;
// Drag-to-zoom on the preview chart updates the (URL-synced) time window,
// exactly as on the dashboard.
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
const onSave = useCallback(async (): Promise<void> => {
try {
// Bake the live query into the spec so unstaged edits are saved too.
await save(buildSaveSpec(draft.spec));
toast.success('Panel saved');
onSaved();
} catch {
toast.error('Failed to save panel');
}
}, [save, buildSaveSpec, draft.spec, onSaved]);
return (
<div className={styles.page} data-testid="panel-editor-v2">
<Header
isDirty={isDirty}
isSaving={isSaving}
onSave={onSave}
onClose={onClose}
/>
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<ResizablePanelGroup
id="panel-editor-v2-main"
orientation="vertical"
defaultLayout={mainDefaultLayout}
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
<PreviewPane
panelId={panelId}
panel={draft}
panelDef={panelDef}
data={data}
isLoading={isLoading}
error={error}
onDragSelect={onDragSelect}
/>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<PanelEditorQueryBuilder
panelType={panelType}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
>
<ConfigPane
panelKind={draft.spec?.plugin?.kind}
spec={spec}
onChangeSpec={setSpec}
legendSeries={legendSeries}
tableColumns={tableColumns}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
export default PanelEditorContainer;

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