Compare commits

..

45 Commits

Author SHA1 Message Date
Abhi Kumar
5a5f46f031 feat(dashboards-v2): switch panel visualization type in the editor
Add a panel-type switcher to the Visualization config section that changes a
panel's kind in place. Switching is reversible within an edit session via a
per-kind cache (Table → List → Table restores the original query + spec); a
first visit rebuilds the query for the new type and resets the plugin spec,
carrying unit/decimals. Every kind now declares a visualization section so the
switcher is reachable from any panel, and it disables types whose supported
signals exclude the current datasource.

Reuse ConfigSelect (extended with per-option disabled + node icons) for the
control, type the panel signal as TelemetrytypesSignalDTO across the editor,
and add EDIT_MODE.md documenting V2 draft/edit mode.
2026-06-23 14:48:23 +05:30
Abhi Kumar
76f17a88bf docs(dashboards-v2): trim verbose comments to minimal-why style
Condense multi-paragraph block comments and drop comments that restate
the code across the new-panel editor surface, keeping only the non-obvious
"why" and concise JSDoc on functions and type fields. Comment-only — no
code, types, or test assertions changed.
2026-06-23 14:46:43 +05:30
Abhi Kumar
31393720db fix(dashboards-v2): preserve list columns across datasource round-trips
The List panel stores a single selectFields list, so switching the query
signal replaced the columns with the new datasource's defaults — going
logs -> traces -> logs discarded a customized logs selection.

Remember each signal's columns and restore them when switching back;
defaults still seed a signal the first time it's seen.
2026-06-23 14:46:43 +05:30
Abhi Kumar
9905305a10 feat(dashboards-v2): seed new panels with per-kind config defaults
A new panel was seeded with an empty plugin spec, so the config pane's
dropdowns and segmented controls (time scope, legend position, line style /
interpolation, fill mode) opened with nothing selected.

Derive a default plugin spec from each kind's declared sections via a new pure
buildDefaultPluginSpec helper and seed it in createDefaultPanel. Each default
equals the matching renderer fallback, so only the config-pane display changes,
not the rendered output. Controls whose empty state already reads as the chart
default (unit/decimals "auto", switches, numeric "Auto" inputs) are left unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:46:43 +05:30
Abhi Kumar
7ba139fc58 feat(dashboards-v2): show the panel header in the editor preview
Reuse PanelHeader in PreviewPane so the editor preview shows the panel
title, description tooltip, refetch spinner, and error/warning indicators,
with the body rendered flush beneath it like the dashboard grid panel.

Add a hideActions flag to PanelHeader to suppress the actions menu in the
editor: View/Edit/Clone/Delete don't apply there, and omitting panelActions
alone isn't enough since View/Edit/Download survive their own gates. Wire the
table/list header search through the preview as the grid does, and thread the
raw isFetching for the header's refetch spinner.
2026-06-23 14:46:43 +05:30
Abhi Kumar
74a975aa04 fix(dashboards-v2): default a threshold's label to an empty string on save 2026-06-23 14:46:43 +05:30
Abhi Kumar
092c778d8a refactor(dashboards-v2): let consumers own the refetch loader signal
usePanelQuery returns the raw react-query isLoading; the dashboard panel and the
editor preview now compute isLoading || isFetching themselves, so each surface
decides whether a background refetch shows the full loader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:46:43 +05:30
Abhi Kumar
a57bf66a83 feat(dashboards-v2): panel header title with description tooltip
Replace PanelHeader's ReactNode title with explicit name + description props,
rendering the description as an info-icon tooltip in the header instead of a
wrapper around the title. Panel passes them directly (no headerTitle memo).
2026-06-23 14:46:43 +05:30
Abhi Kumar
fc3ab3c566 fix(dashboards-v2): derive the preview "Plotted with" tag from the panel query
The editor preview hardcoded EQueryType.QUERY_BUILDER, so PromQL/ClickHouse
panels were mislabelled and list panels still showed the tag. Add a V2 PlotTag
(mirroring V1: hidden for list panels and before a query exists) fed by a new
getPanelQueryType util that reads the panel's V5 envelopes.
2026-06-23 14:46:43 +05:30
Abhi Kumar
cf82b35406 feat(dashboards-v2): unified panel empty, no-data and no-query states
Add a shared PanelMessage component (icon + title + description + optional
action) backing every non-chart panel state. PanelBody now shows a 'Nothing to
visualize yet' state for panels with no runnable query and a polished error
state with Retry. NoData becomes a 'No data in this time range' affordance with
an optional Retry, wired through a new optional refetch on the renderer props.
2026-06-23 14:46:43 +05:30
Abhi Kumar
46f473a050 fix(uplot): center a single-item chart legend
isSingleRow read the container width from a ref inside useMemo, so it computed
once at mount (ref still null → width 0 → false) and only recovered if the
component happened to re-render. The uPlot legend re-renders on plot sync so it
recovered by luck; the Pie legend doesn't, leaving a single item clipped in a
narrow grid track. Measure the container with useResizeObserver instead.
2026-06-23 14:46:43 +05:30
Abhi Kumar
e79b639318 fix(dashboards-v2): place a new panel in the last row's free space
createPanelOps placed every new panel at x:0 on a fresh row, so a half-full
last row left its right half empty. Find the first free slot in the section's
last row (right of its panels) and only wrap to a new row when it can't fit.
2026-06-23 14:46:43 +05:30
Abhi Kumar
f98866682c feat(dashboards-v2): create new panels from the editor
Add a draft-first create flow: picking a panel type opens the editor at
`/panel/new?panelKind=…&layoutIndex=…` on an in-memory default panel, and
nothing is persisted until save — cancelling leaves the dashboard
untouched (V1 parity). On save a new panel is minted (uuid) and added to
the target section via `createPanelOps` (resolves the section, or creates
one when the dashboard has none).

All "Add panel" triggers (section header, empty section, empty dashboard,
toolbar) route through a single useCreatePanel hook + the V2 type picker;
the leftover V1 global modal and useAddPanelToSection are removed.

New panels seed sensible defaults from the kind's supported signals: the
query datasource (e.g. List → logs, not the unsupported metrics default)
and, for List, the signal's default columns — so the Columns control
isn't empty on first open. New panels always re-serialize their query for
the kind on save, so the persisted query is valid even if untouched.

echo "--- working tree after commits (should be only dev hack) ---"; git status --short
2026-06-23 14:46:42 +05:30
Abhi Kumar
b2a160a6be refactor(dashboards-v2): narrow renderer props to the panel kind
PanelRendererProps<K> now carries a `panel` narrowed to kind K (via a
PluginOfKind helper that picks the variant from the generated plugin
union), so each renderer reads `panel.spec.plugin.spec` as its exact spec
DTO. Removes the per-renderer `as <Kind>SpecDTO` cast (and the now-dead
`?? {}` fallbacks) across all seven renderers; the single unavoidable
widening stays in getPanelDefinition.
2026-06-23 14:46:42 +05:30
Abhi Kumar
da6fafc996 refactor(dashboards-v2): tighten the getPanelDefinition cast
Drop the dead `if (!kind)` guard (a partial registry already returns
undefined for absent keys) and reduce the `as unknown as` double-cast to
a single `as` — comparability clears it, so the unknown laundering was
unnecessary.
2026-06-23 14:46:42 +05:30
Abhi Kumar
9f3e78a390 refactor(dashboards-v2): drop the unsupported-panel fallback
Every panel kind now has a renderer, so the "not yet supported in V2"
body is dead. Remove UnsupportedPanelBody and render the panel body
behind a plain `panelDefinition &&` guard. Also clean up the panel-kind
typing in Panel.tsx: `spec.plugin.kind` is non-optional and already a
PanelKind, so the `as unknown as` cast and `?.` chains go away.
2026-06-23 14:46:42 +05:30
Abhi Kumar
e77b3f2530 refactor(dashboards-v2): clean up comments across ListPanel components 2026-06-23 14:44:02 +05:30
Abhi Kumar
d1fca96c97 refactor(dashboards-v2): de-duplicate panel cell + column-unit helpers
- collapse the per-panel stringifyCell copies onto the shared coerceToString
  util (list + table column builders)
- extract the column-unit resolver to Panels/utils/getColumnUnit; the table
  renderer and the editor's column options now share one implementation,
  aligning the editor onto the renderer's "empty entry = no unit" semantics
- export a single TableRowData type from tableColumns (with the antd key)
  instead of two divergent local copies
2026-06-23 14:44:00 +05:30
Abhi Kumar
e622cc0841 refactor(dashboards-v2): address list-panel PR review feedback
- type the telemetry signal as TelemetrytypesSignalDTO (enum) and drop the
  raw-string comparisons across the list panel renderer, columns, row
  interaction, prep util, and the columns-switch hook
- make prepareRawTable's selectFields a required array; make the log
  drawer's selectedTab always defined (defaults to OVERVIEW)
- read/write the list spec via spec.plugin.spec (plugin is non-optional)
- move isPlainObject (lodash) / coerceToString / toFiniteNumber out of
  rawRowToILog into shared utils
- replace the columns editor's hand-built popover + native buttons with
  @signozhq/ui Button and a searchable Combobox
- switch ListColumnsEditor tests to userEvent; trim verbose comments
2026-06-23 14:43:38 +05:30
Abhi Kumar
d2a9f66191 style(dashboards-v2): format list panel header with oxfmt 2026-06-23 14:43:38 +05:30
Abhi Kumar
1dab84654e feat(dashboards-v2): move list columns editor below the query builder
Replace the config-pane Columns section with a dnd-kit reorderable editor
rendered beneath the query builder (V1 parity); sanitize selectFields to the
field-key DTO so saved columns drop non-contract keys (isIndexed).
2026-06-23 14:43:38 +05:30
Abhi Kumar
92f1071dcf feat(dashboards-v2): list columns editor with datasource column switch 2026-06-23 14:43:38 +05:30
Abhi Kumar
de6f7c7271 feat(dashboards-v2): list panel kind (logs/traces) with row detail 2026-06-23 14:43:38 +05:30
Abhi Kumar
18c3122f96 feat(dashboards-v2): prepare V5 raw tables for the list panel 2026-06-23 14:43:38 +05:30
Abhi Kumar
48c8f9ed70 fix(dashboards-v2): keep clickhouse table columns distinct
A scalar/table panel backed by a clickhouse_sql query collapsed every value
column onto the query name: getColName/getColId had no clickhouse handling,
so all value columns resolved to the query name ("A") and the row builder
overwrote each cell with the last column's value.

clickhouse_sql queries carry no aggregation metadata but the response column
holds the user's real SQL alias in col.name. Thread the request's
clickhouse_sql query names through, and name/key those columns by col.name so
each stays distinct. Ports the V2 half of the merged V1 fix (#11794).
2026-06-23 14:42:47 +05:30
Abhi Kumar
564737a851 feat(dashboards-v2): resizable, persisted table columns 2026-06-23 14:42:47 +05:30
Abhi Kumar
85a1458061 feat(dashboards-v2): table panel renderer with record-table prep 2026-06-23 14:42:47 +05:30
Abhi Kumar
7a9adb61be refactor(dashboards-v2): address config-pane review feedback
- Drop unnecessary optional chaining on the always-present panel spec
  (display / plugin / data); type the formatting cast via PanelFormattingSlice.
- Rename the section-lens read/write to get/update (+ slice helpers).
- Derive the histogram NumericBound from the buckets DTO.
- Use userEvent (via tests/test-utils) in the Axes/Buckets/Thresholds tests.
- Use the SigNoz Button for the legend-color Reset action.
2026-06-23 14:32:28 +05:30
Abhi Kumar
97709444cf feat(dashboards-v2): thresholds section editor with per-variant rows 2026-06-22 21:11:38 +05:30
Abhi Kumar
d64336e861 feat(dashboards-v2): chart panel section editors 2026-06-22 21:11:38 +05:30
Abhi Kumar
7980190327 feat(dashboards-v2): config pane shell with reusable form controls 2026-06-22 21:11:35 +05:30
Abhi Kumar
e4b75331b3 refactor(dashboards-v2): localize the perses adapter's envelope casts
The adapter bridges the generated query-envelope DTO (enum type, undiscriminated
spec) and the hand-written QueryEnvelope (typed spec) the V1 mappers consume —
nominally distinct types for the same wire shape, so a structural cast is
unavoidable. Confine those casts to two named `*Envelopes` converters and a
builder-query predicate, keep an explicit typed checkpoint for the composite
spec, and correct the stale "Orval erases spec to unknown" comments.
2026-06-22 21:10:21 +05:30
Abhi Kumar
ed0e11c371 fix(dashboards-v2): harden list pagination against bad inputs
Clamp the page size and offset to finite, positive values before deriving
pageIndex/canNext so the pager can never produce NaN/Infinity/negative state,
and ignore a non-positive setPageSize so the size state stays valid. canNext
now uses `rowCount >= pageSize` to also cover an over-fetch.

Add edge-case tests: empty/non-raw response, full vs partial page, nextCursor,
goNext advancing the page, and a rejected zero page size.
2026-06-22 20:37:51 +05:30
Abhi Kumar
57185fa2c4 refactor(dashboards-v2): trim comment noise and use JSDoc for function docs
Reduce over-commenting flagged in review across the V2 dashboard code:
remove comments that restate the code, shrink verbose blocks to a line, and
keep only the non-obvious "why". Function/helper docs are written as JSDoc;
inline implementation notes stay as line comments.
2026-06-22 18:50:57 +05:30
Abhi Kumar
bdd2c0ca13 refactor(dashboards-v2): address panel-editor foundation PR review
- fix failing lint: drop the unused Typography import in PanelStatusContent
- drop redundant optional chaining on the required spec/plugin (usePanelQuery,
  PanelEditor index, HistogramPanel sections)
- move ComparisonThresholdShape into types/threshold
- derive PanelActionId from keyof PanelActionCapabilities
- fold the per-kind header search flag into actions (drop PanelHeaderControls)
- default PanelHeader searchTerm to ''
- drive the editor's discard prompt through useConfirmableAction
- trim over-verbose comments; PanelHeader test uses userEvent
2026-06-22 18:49:40 +05:30
Abhi Kumar
b124c29ff6 feat(dashboards-v2): richer panel status popover for errors and warnings
Replace the plain status tooltip with a card: variant-coloured icon, the
error/warning code and message, an optional Open Docs link, a MESSAGES
count pill, and the per-item message list. Hosted in @signozhq/ui
TooltipSimple with its padding/width cap stripped so the card owns its
own layout. Bump the header actions gap so the status icon sits clear of
its neighbours.
2026-06-22 18:49:40 +05:30
Abhi Kumar
b66eaaaba7 refactor(dashboards-v2): render the editor preview through the shared panel body
The editor preview duplicated PanelBody's loading/error/renderer state
machine. Delegate to PanelBody so the preview is the production render
path, differing only by panelMode (DASHBOARD_EDIT) and the forwarded
server pager. PanelBody's panelMode and dashboardPreference become
optional (the preview has no dashboard-wide preferences), and its error
state now surfaces the backend message via panelStatusFromError instead
of the raw axios "status code 4xx".
2026-06-22 18:49:40 +05:30
Abhi Kumar
da8c0bab3a feat(dashboards-v2): optional footer slot below the query builder 2026-06-22 18:49:40 +05:30
Abhi Kumar
5875d29d6a fix(dashboards-v2): persist raw/list panels as a bare BuilderQuery 2026-06-22 18:49:40 +05:30
Abhi Kumar
2932d1c49d fix(dashboards-v2): make the editor dirty-check immune to query re-serialization 2026-06-22 18:49:40 +05:30
Abhi Kumar
c8de0838f2 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-22 18:49:40 +05:30
Abhi Kumar
39167df22d feat(dashboards-v2): role and kind-gated panel actions with header chrome 2026-06-22 18:49:40 +05:30
Abhi Kumar
94b407cfcb refactor(dashboards-v2): per-kind panel definitions and registry 2026-06-22 18:49:40 +05:30
Abhi Kumar
93b0b5065b feat(dashboards-v2): panel query hook with pagination and time window 2026-06-22 18:49:39 +05:30
Abhi Kumar
4284b2b6d6 feat(dashboards-v2): pure-V5 perses query adapter and request builder 2026-06-22 18:49:39 +05:30
157 changed files with 3467 additions and 3492 deletions

69
.github/CODEOWNERS vendored
View File

@@ -199,72 +199,3 @@ go.mod @therealpandey
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
## Logs
/frontend/src/pages/Logs/ @SigNoz/events-frontend
/frontend/src/pages/LogsExplorer/ @SigNoz/events-frontend
/frontend/src/pages/LogsModulePage/ @SigNoz/events-frontend
/frontend/src/pages/LogsSettings/ @SigNoz/events-frontend
/frontend/src/pages/LiveLogs/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerChart/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerContext/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerList/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerTable/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerViews/ @SigNoz/events-frontend
/frontend/src/container/LogsFilters/ @SigNoz/events-frontend
/frontend/src/container/LogsSearchFilter/ @SigNoz/events-frontend
/frontend/src/container/LogsTable/ @SigNoz/events-frontend
/frontend/src/container/LogsAggregate/ @SigNoz/events-frontend
/frontend/src/container/LogsContextList/ @SigNoz/events-frontend
/frontend/src/container/LogsIndexToFields/ @SigNoz/events-frontend
/frontend/src/container/LogsLoading/ @SigNoz/events-frontend
/frontend/src/container/LogsPanelTable/ @SigNoz/events-frontend
/frontend/src/container/LogControls/ @SigNoz/events-frontend
/frontend/src/container/LogDetailedView/ @SigNoz/events-frontend
/frontend/src/container/LogExplorerQuerySection/ @SigNoz/events-frontend
/frontend/src/container/LogLiveTail/ @SigNoz/events-frontend
/frontend/src/container/LiveLogs/ @SigNoz/events-frontend
/frontend/src/container/EmptyLogsSearch/ @SigNoz/events-frontend
/frontend/src/container/NoLogs/ @SigNoz/events-frontend
/frontend/src/components/Logs/ @SigNoz/events-frontend
/frontend/src/components/LogDetail/ @SigNoz/events-frontend
/frontend/src/components/LogsFormatOptionsMenu/ @SigNoz/events-frontend
/frontend/src/hooks/logs/ @SigNoz/events-frontend
## Logs Pipelines
/frontend/src/pages/Pipelines/ @SigNoz/events-frontend
/frontend/src/container/PipelinePage/ @SigNoz/events-frontend
## Traces / Trace Explorer
/frontend/src/pages/Trace/ @SigNoz/events-frontend
/frontend/src/pages/TracesExplorer/ @SigNoz/events-frontend
/frontend/src/pages/TracesModulePage/ @SigNoz/events-frontend
/frontend/src/container/Trace/ @SigNoz/events-frontend
/frontend/src/container/TracesExplorer/ @SigNoz/events-frontend
/frontend/src/container/TracesTableComponent/ @SigNoz/events-frontend
## Trace Funnels
/frontend/src/pages/TracesFunnels/ @SigNoz/events-frontend
/frontend/src/pages/TracesFunnelDetails/ @SigNoz/events-frontend
/frontend/src/hooks/TracesFunnels/ @SigNoz/events-frontend
## Trace Details
/frontend/src/pages/TraceDetailsV3/ @SigNoz/events-frontend
/frontend/src/pages/TraceDetailOldRedirect/ @SigNoz/events-frontend
/frontend/src/hooks/trace/ @SigNoz/events-frontend
## Exceptions
/frontend/src/pages/AllErrors/ @SigNoz/events-frontend
/frontend/src/pages/ErrorDetails/ @SigNoz/events-frontend
/frontend/src/container/AllError/ @SigNoz/events-frontend
/frontend/src/container/ErrorDetails/ @SigNoz/events-frontend
## External APIs
/frontend/src/pages/ApiMonitoring/ @SigNoz/events-frontend
/frontend/src/container/ApiMonitoring/ @SigNoz/events-frontend
## Messaging Queues
/frontend/src/pages/MessagingQueues/ @SigNoz/events-frontend
/frontend/src/components/MessagingQueues/ @SigNoz/events-frontend
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend

View File

@@ -659,29 +659,6 @@ components:
refreshToken:
type: string
type: object
AuthtypesPostableUser:
properties:
displayName:
type: string
email:
type: string
frontendBaseUrl:
type: string
userRoles:
items:
$ref: '#/components/schemas/AuthtypesPostableUserRole'
type: array
required:
- email
- userRoles
type: object
AuthtypesPostableUserRole:
properties:
id:
type: string
required:
- id
type: object
AuthtypesRelation:
enum:
- create
@@ -10206,7 +10183,7 @@ paths:
- global
/api/v1/invite:
post:
deprecated: true
deprecated: false
description: This endpoint creates an invite for a user
operationId: CreateInvite
requestBody:
@@ -10269,7 +10246,7 @@ paths:
- users
/api/v1/invite/bulk:
post:
deprecated: true
deprecated: false
description: This endpoint creates a bulk invite for a user
operationId: CreateBulkInvite
requestBody:
@@ -13110,7 +13087,7 @@ paths:
- tracedetail
/api/v1/user:
get:
deprecated: true
deprecated: false
description: This endpoint lists all users
operationId: ListUsersDeprecated
responses:
@@ -13203,7 +13180,7 @@ paths:
tags:
- users
get:
deprecated: true
deprecated: false
description: This endpoint returns the user by id
operationId: GetUserDeprecated
parameters:
@@ -13260,7 +13237,7 @@ paths:
tags:
- users
put:
deprecated: true
deprecated: false
description: This endpoint updates the user by id
operationId: UpdateUserDeprecated
parameters:
@@ -13329,7 +13306,7 @@ paths:
- users
/api/v1/user/me:
get:
deprecated: true
deprecated: false
description: This endpoint returns the user I belong to
operationId: GetMyUserDeprecated
responses:
@@ -20745,68 +20722,6 @@ paths:
summary: List users v2
tags:
- users
post:
deprecated: false
description: This endpoint creates a user for the organization
operationId: CreateUser
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPostableUser'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
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
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create user
tags:
- users
/api/v2/users/{id}:
get:
deprecated: false

View File

@@ -98,15 +98,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
aiObservability := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
Active: aiObservability,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -2258,32 +2258,6 @@ export interface AuthtypesPostableRotateTokenDTO {
refreshToken?: string;
}
export interface AuthtypesPostableUserRoleDTO {
/**
* @type string
*/
id: string;
}
export interface AuthtypesPostableUserDTO {
/**
* @type string
*/
displayName?: string;
/**
* @type string
*/
email: string;
/**
* @type string
*/
frontendBaseUrl?: string;
/**
* @type array
*/
userRoles: AuthtypesPostableUserRoleDTO[];
}
export interface AuthtypesRoleDTO {
/**
* @type string
@@ -10833,14 +10807,6 @@ export type ListUsers200 = {
status: string;
};
export type CreateUser201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type GetUserPathParameters = {
id: string;
};

View File

@@ -18,11 +18,9 @@ import type {
} from 'react-query';
import type {
AuthtypesPostableUserDTO,
CreateInvite201,
CreateResetPasswordToken201,
CreateResetPasswordTokenPathParameters,
CreateUser201,
DeleteUserPathParameters,
GetMyUser200,
GetMyUserDeprecated200,
@@ -171,7 +169,6 @@ export const invalidateGetResetPasswordTokenDeprecated = async (
/**
* This endpoint creates an invite for a user
* @deprecated
* @summary Create invite
*/
export const createInvite = (
@@ -233,7 +230,6 @@ export type CreateInviteMutationBody =
export type CreateInviteMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create invite
*/
export const useCreateInvite = <
@@ -256,7 +252,6 @@ export const useCreateInvite = <
};
/**
* This endpoint creates a bulk invite for a user
* @deprecated
* @summary Create bulk invite
*/
export const createBulkInvite = (
@@ -318,7 +313,6 @@ export type CreateBulkInviteMutationBody =
export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create bulk invite
*/
export const useCreateBulkInvite = <
@@ -424,7 +418,6 @@ export const useResetPassword = <
};
/**
* This endpoint lists all users
* @deprecated
* @summary List users
*/
export const listUsersDeprecated = (signal?: AbortSignal) => {
@@ -470,7 +463,6 @@ export type ListUsersDeprecatedQueryResult = NonNullable<
export type ListUsersDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary List users
*/
@@ -494,7 +486,6 @@ export function useListUsersDeprecated<
}
/**
* @deprecated
* @summary List users
*/
export const invalidateListUsersDeprecated = async (
@@ -590,7 +581,6 @@ export const useDeleteUser = <
};
/**
* This endpoint returns the user by id
* @deprecated
* @summary Get user
*/
export const getUserDeprecated = (
@@ -650,7 +640,6 @@ export type GetUserDeprecatedQueryResult = NonNullable<
export type GetUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Get user
*/
@@ -677,7 +666,6 @@ export function useGetUserDeprecated<
}
/**
* @deprecated
* @summary Get user
*/
export const invalidateGetUserDeprecated = async (
@@ -695,7 +683,6 @@ export const invalidateGetUserDeprecated = async (
/**
* This endpoint updates the user by id
* @deprecated
* @summary Update user
*/
export const updateUserDeprecated = (
@@ -768,7 +755,6 @@ export type UpdateUserDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Update user
*/
export const useUpdateUserDeprecated = <
@@ -797,7 +783,6 @@ export const useUpdateUserDeprecated = <
};
/**
* This endpoint returns the user I belong to
* @deprecated
* @summary Get my user
*/
export const getMyUserDeprecated = (signal?: AbortSignal) => {
@@ -843,7 +828,6 @@ export type GetMyUserDeprecatedQueryResult = NonNullable<
export type GetMyUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Get my user
*/
@@ -867,7 +851,6 @@ export function useGetMyUserDeprecated<
}
/**
* @deprecated
* @summary Get my user
*/
export const invalidateGetMyUserDeprecated = async (
@@ -1226,89 +1209,6 @@ export const invalidateListUsers = async (
return queryClient;
};
/**
* This endpoint creates a user for the organization
* @summary Create user
*/
export const createUser = (
authtypesPostableUserDTO?: BodyType<AuthtypesPostableUserDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateUser201>({
url: `/api/v2/users`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: authtypesPostableUserDTO,
signal,
});
};
export const getCreateUserMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUser>>,
TError,
{ data?: BodyType<AuthtypesPostableUserDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createUser>>,
TError,
{ data?: BodyType<AuthtypesPostableUserDTO> },
TContext
> => {
const mutationKey = ['createUser'];
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 createUser>>,
{ data?: BodyType<AuthtypesPostableUserDTO> }
> = (props) => {
const { data } = props ?? {};
return createUser(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateUserMutationResult = NonNullable<
Awaited<ReturnType<typeof createUser>>
>;
export type CreateUserMutationBody =
| BodyType<AuthtypesPostableUserDTO>
| undefined;
export type CreateUserMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create user
*/
export const useCreateUser = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUser>>,
TError,
{ data?: BodyType<AuthtypesPostableUserDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createUser>>,
TError,
{ data?: BodyType<AuthtypesPostableUserDTO> },
TContext
> => {
return useMutation(getCreateUserMutationOptions(options));
};
/**
* This endpoint returns the user by id
* @summary Get user by user id

View File

@@ -12,5 +12,4 @@ export enum FeatureKeys {
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
USE_DASHBOARD_V2 = 'use_dashboard_v2',
EMABLE_AI_OBSERVABILITY = 'enable_ai_observability',
}

View File

@@ -5,6 +5,7 @@ import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { Check, Copy } from '@signozhq/icons';
@@ -36,17 +37,16 @@ export default function Legend({
// Search is intrinsic to the right-positioned legend.
const searchEnabled = position === LegendPosition.RIGHT;
const { width: containerWidth } = useResizeObserver(legendContainerRef);
const isSingleRow = useMemo(() => {
if (!legendContainerRef.current || position !== LegendPosition.BOTTOM) {
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
return false;
}
const containerWidth = legendContainerRef.current.clientWidth;
const totalLegendWidth = items.length * (averageLegendWidth + 16);
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
return totalRows <= 1;
}, [averageLegendWidth, items.length, position]);
}, [averageLegendWidth, items.length, position, containerWidth]);
const visibleLegendItems = useMemo(() => {
if (!searchEnabled || !legendSearchQuery.trim()) {

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
@@ -13,10 +13,11 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import { useAppContext } from 'providers/App/App';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useCreatePanel } from '../hooks/useCreatePanel';
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
@@ -49,9 +50,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const createPanel = useCreatePanel();
const [isAddingPanel, setIsAddingPanel] = useState(false);
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -107,8 +107,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
void logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
setIsPanelTypeSelectionModalOpen(true);
}, [id, setIsPanelTypeSelectionModalOpen]);
setIsAddingPanel(true);
}, [id]);
return (
<section className={styles.dashboardPageToolbarContainer}>
@@ -140,6 +140,14 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
</div>
<VariablesBar dashboard={dashboard} />
<PanelTypeSelectionModal
open={isAddingPanel}
onClose={(): void => setIsAddingPanel(false)}
onSelect={(pluginKind): void => {
setIsAddingPanel(false);
createPanel({ pluginKind });
}}
/>
</section>
);
}

View File

@@ -1,34 +1,24 @@
import { useEffect, useMemo, useState } from 'react';
import { Info } from '@signozhq/icons';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- fixed-option signal picker
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
import { Select } from 'antd';
import { CustomSelect } from 'components/NewSelect';
import TextToolTip from 'components/TextToolTip';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import useDebounce from 'hooks/useDebounce';
import { isRetryableError } from 'utils/errorUtils';
import {
DYNAMIC_SIGNAL_LABEL,
DYNAMIC_SIGNALS,
type DynamicSignalOption,
signalForApi,
} from '../variableFormModel';
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
import styles from './VariableForm.module.scss';
interface DynamicVariableFieldsProps {
attribute: string;
signal: DynamicSignalOption;
signal: TelemetrySignal;
onChange: (patch: {
dynamicAttribute?: string;
dynamicSignal?: DynamicSignalOption;
dynamicSignal?: TelemetrySignal;
}) => void;
onPreview: (values: (string | number)[]) => void;
/** Inline error shown under the attribute field (e.g. duplicate attribute). */
attributeError?: string;
}
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
@@ -37,24 +27,18 @@ function DynamicVariableFields({
signal,
onChange,
onPreview,
attributeError,
}: DynamicVariableFieldsProps): JSX.Element {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const apiSignal = signalForApi(signal);
const {
data: keyData,
isLoading,
error,
refetch,
} = useGetFieldKeys({
signal: apiSignal,
const { data: keyData, isLoading } = useGetFieldKeys({
signal,
name: debouncedSearch || undefined,
});
// `keys` is a Record keyed BY field name; the field names are the map keys.
// CustomSelect filters the supplied options locally as the user types.
// When the API reports the list is `complete`, search filters locally.
const isComplete = keyData?.data?.complete === true;
const options = useMemo(
() =>
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
@@ -65,7 +49,7 @@ function DynamicVariableFields({
);
const { data: valueData } = useGetFieldValues({
signal: apiSignal,
signal,
name: attribute,
enabled: !!attribute,
});
@@ -78,60 +62,40 @@ function DynamicVariableFields({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [valueData]);
const errorMessage = error ? (error as Error).message || null : null;
return (
<>
<div className={cx(styles.row, styles.sortSection)}>
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Source</Typography.Text>
<TextToolTip
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<Select
<SelectSimple
className={styles.sortSelect}
popupMatchSelectWidth={false}
value={signal}
options={DYNAMIC_SIGNALS.map((s) => ({
label: DYNAMIC_SIGNAL_LABEL[s],
value: s,
}))}
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
onChange={(value): void =>
onChange({ dynamicSignal: value as DynamicSignalOption })
onChange({ dynamicSignal: value as TelemetrySignal })
}
data-testid="variable-signal-select"
testId="variable-signal-select"
/>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Attribute</Typography.Text>
</div>
<CustomSelect
<Select
className={styles.searchSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
loading={isLoading}
options={options}
filterOption={isComplete}
onSearch={setSearch}
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
noDataMessage="No fields found"
errorMessage={errorMessage}
onRetry={(): void => {
void refetch();
}}
showRetryButton={error ? isRetryableError(error) : true}
options={options}
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
data-testid="variable-field-select"
/>
</div>
{attributeError ? (
<Typography.Text className={styles.errorText}>
{attributeError}
</Typography.Text>
) : null}
</>
);
}

View File

@@ -1,139 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- fixed-option sort picker
import { Select } from 'antd';
import { CustomSelect } from 'components/NewSelect';
import {
VARIABLE_SORT_LABEL,
VARIABLE_SORTS,
type VariableFormModel,
type VariableSort,
} from '../variableFormModel';
import styles from './VariableForm.module.scss';
interface ListVariableFieldsProps {
model: VariableFormModel;
onChange: (patch: Partial<VariableFormModel>) => void;
previewValues: (string | number)[];
previewError: string | null;
defaultValue: string;
onDefaultValueChange: (value: string) => void;
/** Whether the "ALL values" toggle applies to this type (QUERY / CUSTOM). */
showAllOptionField: boolean;
}
/**
* Rows shared by the list-style variables (Query / Custom / Dynamic): the value
* preview, sort, multi-select / ALL toggles and the default-value picker.
*/
function ListVariableFields({
model,
onChange,
previewValues,
previewError,
defaultValue,
onDefaultValueChange,
showAllOptionField,
}: ListVariableFieldsProps): JSX.Element {
return (
<>
<div className={cx(styles.row, styles.previewSection)}>
<Typography.Text className={styles.previewLabel}>
Preview of Values
</Typography.Text>
<div className={styles.previewValues}>
{previewError ? (
<Typography.Text className={styles.previewError}>
{previewError}
</Typography.Text>
) : (
previewValues.map((value, idx) => (
<Badge
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
key={`${value}-${idx}`}
color="vanilla"
>
{value.toString()}
</Badge>
))
)}
</div>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
</div>
<Select
className={styles.sortSelect}
popupMatchSelectWidth={false}
value={model.sort}
options={VARIABLE_SORTS.map((sort) => ({
label: VARIABLE_SORT_LABEL[sort],
value: sort,
}))}
onChange={(value): void => onChange({ sort: value as VariableSort })}
data-testid="variable-sort-select"
/>
</div>
<div className={cx(styles.row, styles.multiSection)}>
<Typography.Text className={styles.rowLabel}>
Enable multiple values to be checked
</Typography.Text>
<Switch
value={model.multiSelect}
onChange={(checked): void =>
onChange({
multiSelect: checked,
showAllOption: checked ? model.showAllOption : false,
})
}
testId="variable-multi-switch"
/>
</div>
{model.multiSelect && showAllOptionField ? (
<div className={cx(styles.row, styles.allOptionSection)}>
<Typography.Text className={styles.rowLabel}>
Include an option for ALL values
</Typography.Text>
<Switch
value={model.showAllOption}
onChange={(checked): void => onChange({ showAllOption: checked })}
testId="variable-all-switch"
/>
</div>
) : null}
<div className={cx(styles.row, styles.defaultValueSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Default Value</Typography.Text>
<Typography.Text className={styles.defaultValueDesc}>
{model.type === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography.Text>
</div>
<CustomSelect
className={styles.searchSelect}
showSearch
allowClear
placeholder="Select a default value"
value={defaultValue || undefined}
onChange={(value): void => onDefaultValueChange((value as string) ?? '')}
options={previewValues.map((value) => ({
label: value.toString(),
value: value.toString(),
}))}
data-testid="variable-default-select"
/>
</div>
</>
);
}
export default ListVariableFields;

View File

@@ -3,14 +3,14 @@ import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import Editor from 'components/Editor';
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { VariableSort } from '../variableModel';
import styles from './VariableForm.module.scss';
interface QueryVariableFieldsProps {
queryValue: string;
/** Sibling variable selections, so dependent `$vars` in the query resolve. */
variables: PayloadVariables;
sort: VariableSort;
onChange: (queryValue: string) => void;
onPreview: (values: (string | number)[]) => void;
onError: (message: string | null) => void;
@@ -19,7 +19,7 @@ interface QueryVariableFieldsProps {
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
function QueryVariableFields({
queryValue,
variables,
sort,
onChange,
onPreview,
onError,
@@ -30,21 +30,20 @@ function QueryVariableFields({
setIsRunning(true);
onError(null);
try {
const res = await dashboardVariablesQuery({ query: queryValue, variables });
const res = await dashboardVariablesQuery({
query: queryValue,
variables: {},
});
if (res.statusCode === 200 && res.payload) {
onPreview(res.payload.variableValues ?? []);
onPreview(
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
);
} else {
onError(res.error || 'Failed to run query');
onPreview([]);
}
} catch (err) {
// `dashboardVariablesQuery` throws `{ message, details: { error } }`.
const detail = (err as { details?: { error?: string } }).details?.error;
const message =
detail && detail.includes('Syntax error:')
? 'Please make sure query is valid and dependent variables are selected'
: detail || (err as Error).message || 'Failed to run query';
onError(message);
onError((err as Error).message || 'Failed to run query');
onPreview([]);
} finally {
setIsRunning(false);

View File

@@ -5,8 +5,22 @@
.container {
display: flex;
flex-direction: column;
border: 1px solid var(--l1-border);
border-radius: 3px;
border: 1px solid var(--l2-border);
}
.allVariables {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--l1-border);
}
.allVariablesBtn {
--button-height: 24px;
--button-padding: 0;
color: var(--muted-foreground);
}
.content {
@@ -28,12 +42,6 @@
width: 200px;
}
.sourceLabel {
display: flex;
align-items: center;
gap: 6px;
}
.label {
color: var(--l2-foreground);
font-family: Inter;
@@ -51,7 +59,7 @@
.textarea,
.defaultInput {
padding: 6px 6px 6px 8px;
border: 1px solid var(--l2-border);
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l3-background);
}
@@ -70,89 +78,48 @@
color: var(--bg-amber-500);
}
/* Variable type — Tabs root composing the picker row + per-type body panels. */
/* Variable type segmented group */
.typeSection {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 40px;
margin-bottom: 0;
}
/* Picker row (label left, tabs right); the bottom divider separates type from
config. Single line — the tab row scrolls (never wraps) when narrow. */
.typePicker {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--l2-border);
@media (max-width: 1440px) {
flex-wrap: wrap;
}
}
/* Active tab panel — reset the Tabs default padding; body rows handle spacing. */
.typePanel {
padding: 0 !important;
}
.typeContent {
display: flex;
flex-direction: column;
gap: 20px;
}
.typeLabelContainer {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
width: auto;
}
/* Horizontal scroll so the tab row never wraps to a second line. The scrollbar
is hidden — the row stays a single crisp line and scrolls only when narrow. */
.typeTabsScroll {
justify-self: flex-end;
--tab-list-wrapper-secondary-padding-left: 0;
}
/* Connected segmented control, mirroring Overview's SegmentedControl: no outer
padding, segments divided by 1px borders, active segment filled + bold. */
.typeTabs {
display: inline-flex;
flex-wrap: nowrap;
width: max-content;
gap: 0;
padding: 0;
border: 1px solid var(--l2-border);
border-radius: 2px;
background: transparent;
}
.typeTab {
display: inline-flex;
.typeBtnGroup {
display: grid;
grid-template-columns: repeat(4, max-content);
height: 32px;
flex-shrink: 0;
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l2-background);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
}
.typeBtn {
--button-height: 32px;
display: flex;
align-items: center;
gap: 6px;
min-height: 24px;
padding: 6px 14px;
white-space: nowrap;
justify-content: center;
gap: 4px;
min-width: 114px;
border-radius: 0;
color: var(--l2-foreground);
&:not(:last-child) {
border-right: 1px solid var(--l2-border);
& + & {
border-left: 1px solid var(--l1-border);
}
}
&[data-state='active'] {
color: var(--l1-foreground);
font-weight: 500;
// override the Tabs component's default (transparent) active background.
background: var(--l3-background) !important;
}
.typeBtnSelected {
background: var(--l1-border);
color: var(--l1-foreground);
}
.betaTag {
@@ -171,7 +138,7 @@
.editorWrap {
height: 240px;
overflow: hidden;
border: 1px solid var(--l2-border);
border: 1px solid var(--l1-border);
border-radius: 2px;
}
@@ -187,7 +154,7 @@
.customSection :global(.custom-collapse) {
width: 100%;
border: 1px solid var(--l2-border);
border: 1px solid var(--l1-border);
border-radius: 3px 3px 0 0;
:global(.ant-collapse-item) {
@@ -241,7 +208,7 @@
min-height: 88px;
margin-bottom: 0;
padding-bottom: 8px;
border: 1px solid var(--l2-border);
border: 1px solid var(--l1-border);
border-radius: 3px;
}
@@ -304,9 +271,13 @@
letter-spacing: -0.07px;
}
.sortSelect {
width: 192px;
}
.defaultValueSection {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: max-content 1fr;
gap: 1rem;
align-items: center;
margin-bottom: 0;
@@ -326,21 +297,14 @@
letter-spacing: -0.06px;
}
/* All variable selects (Source / Attribute / Sort / Default Value) share width
and a consistent --l2-border outline. */
.sortSelect,
.searchSelect {
width: 240px;
flex-shrink: 0;
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
width: 100%;
}
.actionButtons {
width: 100%;
/* Footer */
.footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 12px;
}

View File

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

View File

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

View File

@@ -1,93 +0,0 @@
import {
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { TabsList, TabsTrigger } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import TextToolTip from 'components/TextToolTip';
import styles from './VariableForm.module.scss';
/**
* Presentational trigger row for the variable-type tabs (label + segmented
* triggers). Must render inside a `TabsRoot`, which owns the active state and
* change handling; the matching `TabsContent` panels are siblings in the root.
*/
function VariableTypeTabs(): JSX.Element {
return (
<div className={styles.typePicker}>
<div className={styles.typeLabelContainer}>
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<div className={styles.typeTabsScroll}>
<TabsList variant="secondary" className={styles.typeTabs}>
<TabsTrigger
value="DYNAMIC"
className={styles.typeTab}
testId="variable-type-dynamic"
>
<Pyramid size={14} />
Dynamic
<Badge color="robin" className={styles.betaTag}>
Beta
</Badge>
</TabsTrigger>
<TabsTrigger
value="TEXT"
className={styles.typeTab}
testId="variable-type-textbox"
>
<ClipboardType size={14} />
Textbox
</TabsTrigger>
<TabsTrigger
value="CUSTOM"
className={styles.typeTab}
testId="variable-type-custom"
>
<LayoutList size={14} />
Custom
</TabsTrigger>
<TabsTrigger
value="QUERY"
className={styles.typeTab}
testId="variable-type-query"
>
<DatabaseZap size={14} />
Query
<Badge color="amber" className={styles.betaTag}>
Not Recommended
</Badge>
<span
className={styles.betaTag}
onClick={(e): void => e.stopPropagation()}
role="presentation"
>
<TextToolTip
text="Learn why we don't recommend"
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</span>
</TabsTrigger>
</TabsList>
</div>
</div>
);
}
export default VariableTypeTabs;

View File

@@ -1,191 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
import type { VariableSelectionMap } from '../../../VariablesBar/selectionTypes';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { detectVariableCycle } from '../variableDependencies';
import {
sortValuesByOrder,
type VariableFormModel,
type VariableType,
} from '../variableFormModel';
import { getAttributeError, getNameError } from './variableValidation';
// Stable reference so the zustand selector never returns a fresh object (which
// would make useSyncExternalStore loop) when this dashboard has no selections.
const EMPTY_SELECTIONS: VariableSelectionMap = {};
interface UseVariableFormArgs {
initial: VariableFormModel;
siblings: VariableFormModel[];
isNew: boolean;
onSave: (model: VariableFormModel) => void;
}
export interface UseVariableForm {
model: VariableFormModel;
set: (patch: Partial<VariableFormModel>) => void;
onNameChange: (value: string) => void;
selectType: (type: VariableType) => void;
onCustomChange: (value: string) => void;
onDynamicChange: (patch: Partial<VariableFormModel>) => void;
setRawPreview: (values: (string | number)[]) => void;
previewValues: (string | number)[];
previewError: string | null;
setPreviewError: (message: string | null) => void;
defaultValue: string;
setDefaultValue: (value: string) => void;
visibleNameError: string | null;
nameError: string | null;
attributeError: string | undefined;
cycleError: string | null;
isListType: boolean;
showAllOptionField: boolean;
payloadVariables: PayloadVariables;
handleSave: () => void;
}
const readDefaultValue = (model: VariableFormModel): string =>
((model.defaultValue as { value?: string })?.value ?? '') as string;
/** Form state, derivations and handlers for the variable editor. */
export function useVariableForm({
initial,
siblings,
isNew,
onSave,
}: UseVariableFormArgs): UseVariableForm {
const [model, setModel] = useState<VariableFormModel>(initial);
// Raw, unsorted preview; `previewValues` applies the chosen sort so a shown
// preview re-sorts when Sort changes.
const [rawPreview, setRawPreview] = useState<(string | number)[]>([]);
const [previewError, setPreviewError] = useState<string | null>(null);
const [cycleError, setCycleError] = useState<string | null>(null);
// In add mode, mirror the chosen attribute into the name until the user types.
const [nameTouched, setNameTouched] = useState(false);
const [defaultValue, setDefaultValue] = useState<string>(
readDefaultValue(initial),
);
useEffect(() => {
setModel(initial);
setRawPreview([]);
setPreviewError(null);
setCycleError(null);
setNameTouched(false);
setDefaultValue(readDefaultValue(initial));
}, [initial]);
const set = (patch: Partial<VariableFormModel>): void =>
setModel((prev) => ({ ...prev, ...patch }));
const previewValues = useMemo(
() => sortValuesByOrder(rawPreview, model.sort) as (string | number)[],
[rawPreview, model.sort],
);
const existingNames = useMemo(() => siblings.map((v) => v.name), [siblings]);
const existingDynamicAttributes = useMemo(
() =>
siblings
.filter((v) => v.type === 'DYNAMIC' && v.dynamicAttribute)
.map((v) => v.dynamicAttribute),
[siblings],
);
// Sibling selections feed the Query "Test Run" so dependent `$vars` resolve.
const dashboardId = useDashboardStore((s) => s.dashboardId);
const selections = useDashboardStore(
(s) => s.variableValues[dashboardId ?? ''] ?? EMPTY_SELECTIONS,
);
const payloadVariables = useMemo<PayloadVariables>(() => {
const out: PayloadVariables = {};
siblings.forEach((v) => {
if (v.name) {
out[v.name] = selections[v.name]?.value ?? null;
}
});
return out;
}, [siblings, selections]);
const trimmedName = model.name.trim();
const nameError = getNameError(trimmedName, existingNames, initial.name);
// Surface the message only once the field is dirty; Save stays disabled regardless.
const visibleNameError = nameTouched ? nameError : null;
const attributeError = getAttributeError(model, existingDynamicAttributes);
const isListType =
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
const onNameChange = (value: string): void => {
setNameTouched(true);
set({ name: value });
};
const selectType = (type: VariableType): void => {
set({ type });
setRawPreview([]);
setPreviewError(null);
};
const onCustomChange = (value: string): void => {
set({ customValue: value });
setRawPreview(commaValuesParser(value));
};
// In add mode, mirror the selected attribute into the name until the user
// edits the name themselves (matches the V1 dynamic-variable behaviour).
const onDynamicChange = (patch: Partial<VariableFormModel>): void => {
if (isNew && !nameTouched && patch.dynamicAttribute) {
set({ ...patch, name: patch.dynamicAttribute });
} else {
set(patch);
}
};
const handleSave = (): void => {
const next: VariableFormModel = {
...model,
name: trimmedName,
defaultValue: defaultValue ? { value: defaultValue } : undefined,
};
const cycle = detectVariableCycle([...siblings, next]);
if (cycle) {
setCycleError(
`Cannot save: circular dependency detected between variables: ${cycle.join(
' → ',
)}`,
);
return;
}
setCycleError(null);
onSave(next);
};
return {
model,
set,
onNameChange,
selectType,
onCustomChange,
onDynamicChange,
setRawPreview,
previewValues,
previewError,
setPreviewError,
defaultValue,
setDefaultValue,
visibleNameError,
nameError,
attributeError,
cycleError,
isListType,
showAllOptionField,
payloadVariables,
handleSave,
};
}

View File

@@ -1,37 +0,0 @@
import type { VariableFormModel } from '../variableFormModel';
/**
* Name validation, mirroring V1: empty / whitespace are rejected, and the name
* set includes self, but keeping your own (original) name is always allowed.
*/
export function getNameError(
name: string,
existingNames: string[],
originalName: string,
): string | null {
if (name === '') {
return 'Variable name is required';
}
if (/\s/.test(name)) {
return 'Variable name cannot contain whitespaces';
}
if (name !== originalName && existingNames.includes(name)) {
return 'Variable name already exists';
}
return null;
}
/** Rejects a dynamic variable reusing an attribute already bound elsewhere. */
export function getAttributeError(
model: VariableFormModel,
existingDynamicAttributes: string[],
): string | undefined {
if (
model.type === 'DYNAMIC' &&
model.dynamicAttribute &&
existingDynamicAttributes.includes(model.dynamicAttribute)
) {
return 'A variable with this attribute key already exists';
}
return undefined;
}

View File

@@ -1,140 +0,0 @@
import type { CSSProperties } from 'react';
import { Check, GripVertical, PenLine, Trash2, X } from '@signozhq/icons';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { VariableFormModel } from './variableFormModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariableRowProps {
variable: VariableFormModel;
index: number;
canEdit: boolean;
/** True when this row's delete is awaiting inline confirmation. */
isConfirmingDelete: boolean;
onEdit: (index: number) => void;
onRequestDelete: (index: number) => void;
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
}
/** A single draggable variable row (drag handle + meta + inline actions). */
function VariableRow({
variable,
index,
canEdit,
isConfirmingDelete,
onEdit,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
}: VariableRowProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: variable.name });
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
...(isDragging ? { position: 'relative', zIndex: 1, opacity: 0.8 } : {}),
};
return (
<div
ref={setNodeRef}
style={style}
className={styles.row}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
{canEdit ? (
<span
ref={setActivatorNodeRef}
className={styles.dragHandle}
aria-label="Reorder variable"
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</span>
) : null}
<Typography.Text className={styles.varName}>
${variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : null}
</div>
{canEdit && isConfirmingDelete ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && !isConfirmingDelete ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
</div>
);
}
export default VariableRow;

View File

@@ -2,11 +2,13 @@
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 16px;
}
.header {
display: flex;
justify-content: flex-end;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
@@ -28,6 +30,14 @@
color: var(--l2-foreground);
}
.empty {
padding: 32px;
text-align: center;
border: 1px dashed var(--l1-border);
border-radius: 4px;
color: var(--l2-foreground);
}
.list {
display: flex;
flex-direction: column;
@@ -52,15 +62,6 @@
min-width: 0;
}
.dragHandle {
display: flex;
flex-shrink: 0;
align-items: center;
color: var(--l3-foreground);
cursor: grab;
touch-action: none;
}
.varName {
font-weight: 500;
color: var(--l1-foreground);

View File

@@ -1,20 +1,24 @@
import type { DragEndEvent } from '@dnd-kit/core';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
Check,
ChevronDown,
ChevronUp,
PenLine,
Trash2,
X,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import VariableRow from './VariableRow';
import type { VariableFormModel } from './variableFormModel';
import type { VariableFormModel } from './variableModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariablesListProps {
variables: VariableFormModel[];
canEdit: boolean;
@@ -37,48 +41,98 @@ function VariablesList({
onCancelDelete,
onMove,
}: VariablesListProps): JSX.Element {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
);
const handleDragEnd = ({ active, over }: DragEndEvent): void => {
if (!over || active.id === over.id) {
return;
}
const from = variables.findIndex((v) => v.name === active.id);
const to = variables.findIndex((v) => v.name === over.id);
if (from !== -1 && to !== -1) {
onMove(from, to);
}
};
return (
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={variables.map((v) => v.name)}
strategy={verticalListSortingStrategy}
>
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<VariableRow
key={variable.name || `variable-${index}`}
variable={variable}
index={index}
canEdit={canEdit}
isConfirmingDelete={confirmingIndex === index}
onEdit={onEdit}
onRequestDelete={onRequestDelete}
onConfirmDelete={onConfirmDelete}
onCancelDelete={onCancelDelete}
/>
))}
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<div
className={styles.row}
key={variable.name || `variable-${index}`}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
<Typography.Text className={styles.varName}>
${variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : null}
</div>
{canEdit && confirmingIndex === index ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && confirmingIndex !== index ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === 0}
onClick={(): void => onMove(index, index - 1)}
aria-label="Move up"
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === variables.length - 1}
onClick={(): void => onMove(index, index + 1)}
aria-label="Move down"
>
<ChevronDown size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
</div>
</SortableContext>
</DndContext>
))}
</div>
);
}

View File

@@ -1,26 +0,0 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
const AddVariableButton = ({
isEditable,
setIsEditing,
}: {
isEditable: boolean;
setIsEditing: (state: { type: 'new' }) => void;
}): JSX.Element => {
return (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
size="md"
onClick={(): void => setIsEditing({ type: 'new' })}
testId="add-variable"
disabled={!isEditable}
>
Add variable
</Button>
);
};
export default AddVariableButton;

View File

@@ -1,11 +0,0 @@
.backToAllVariables {
gap: 8px;
padding: 8px;
border-bottom: 1px solid var(--l3-border);
}
.backToAllVariablesButton {
--button-font-size: 14px;
--button-padding: var(--spacing-5) var(--spacing-3);
color: var(--l1-foreground);
}

View File

@@ -1,28 +0,0 @@
import { ArrowLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from './BackToAllVariables.module.scss';
import { VariableFormProps } from '../../types';
const BackToAllVariables = ({
onClose,
}: {
onClose: VariableFormProps['onClose'];
}): JSX.Element => {
return (
<div className={styles.backToAllVariables}>
<Button
variant="ghost"
color="secondary"
className={styles.backToAllVariablesButton}
prefix={<ArrowLeft size={14} />}
onClick={onClose}
testId="variable-form-back"
size="md"
>
All variables
</Button>
</div>
);
};
export default BackToAllVariables;

View File

@@ -1,25 +0,0 @@
.noVariablesCard {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.noVariablesCopy {
display: flex;
flex-direction: column;
gap: 2px;
}
.noVariablesTitle {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.noVariablesInfo {
color: var(--l3-foreground);
font-size: 13px;
line-height: 18px;
}

View File

@@ -1,28 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import AddVariableButton from '../AddVariableButton';
import { EditingState } from '../../types';
import styles from './NoVariables.module.scss';
const NoVariablesCard = ({
isEditable,
setIsEditing,
}: {
isEditable: boolean;
setIsEditing: React.Dispatch<React.SetStateAction<EditingState | null>>;
}): JSX.Element => {
return (
<div className={styles.noVariablesCard}>
<div className={styles.noVariablesCopy}>
<Typography.Text className={styles.noVariablesTitle}>
No variables yet
</Typography.Text>
<Typography.Text className={styles.noVariablesInfo}>
Create a variable to parameterize your panel queries.
</Typography.Text>
</div>
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
</div>
);
};
export default NoVariablesCard;

View File

@@ -1,25 +0,0 @@
.infoItemContainer {
display: flex;
flex-direction: column;
gap: 4px;
}
.infoTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
}
.variableNameInput {
border-radius: 2px;
border: 1px solid var(--l2-border);
&::placeholder {
color: var(--l3-foreground);
}
}
.descriptionTextArea {
border-radius: 2px;
border: 1px solid var(--l2-border);
}

View File

@@ -1,60 +0,0 @@
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
import { Input as AntdInput } from 'antd';
import styles from './VariableInfoForm.module.scss';
import variableFormStyles from '../../VariableForm/VariableForm.module.scss';
interface VariableInfoFormProps {
title: string;
description: string;
onTitleChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
visibleNameError: string | null;
}
function VariableInfoForm({
title,
description,
onTitleChange,
onDescriptionChange,
visibleNameError,
}: VariableInfoFormProps): JSX.Element {
return (
<>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Name</Typography>
<Input
testId="variable-name"
className={styles.variableNameInput}
value={title}
onChange={(e): void => onTitleChange(e.target.value)}
placeholder="Unique name of the variable"
/>
{visibleNameError ? (
<Typography.Text className={variableFormStyles.errorText}>
<sup>*</sup>&nbsp;
{visibleNameError}
</Typography.Text>
) : null}
</div>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Description</Typography>
<AntdInput.TextArea
className={styles.descriptionTextArea}
value={description}
placeholder="Enter a description for the variable"
data-testid="dashboard-desc"
rows={3}
onChange={(e): void => onDescriptionChange(e.target.value)}
/>
</div>
</>
);
}
export default VariableInfoForm;

View File

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

View File

@@ -1,18 +0,0 @@
import { VariableFormModel } from './variableFormModel';
/** `null` index = adding a new variable; a number = editing that row. */
export type EditingState =
| { type: 'new' }
| { type: 'edit'; index: number }
| null;
export interface VariableFormProps {
initial: VariableFormModel;
/** The other variables (excluding this one), for uniqueness & cycle checks. */
siblings: VariableFormModel[];
/** True when adding a new variable (enables auto-naming from the attribute). */
isNew: boolean;
isSaving: boolean;
onClose: () => void;
onSave: (model: VariableFormModel) => void;
}

View File

@@ -6,7 +6,7 @@ import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableFormModel';
import type { VariableFormModel } from './variableModel';
import { buildVariablesPatch } from './variablePatchOps';
interface UseSaveVariables {

View File

@@ -4,6 +4,7 @@ import {
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesListVariableSpecDTO,
@@ -13,24 +14,21 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import {
DYNAMIC_SIGNAL_ALL,
type DynamicSignalOption,
emptyVariableFormModel,
signalForApi,
VARIABLE_SORT_DISABLED,
PLUGIN_KIND,
type TelemetrySignal,
type VariableFormModel,
type VariableSort,
} from './variableFormModel';
} from './variableModel';
/** DTO envelope → flat form model (for display / editing). */
export function dtoToFormModel(
dto: DashboardtypesVariableDTO,
): VariableFormModel {
const base = emptyVariableFormModel();
const display = dto.spec.display;
const display = dto.spec?.display;
const common: VariableFormModel = {
...base,
// TODO
name: dto.spec?.name ?? display?.name ?? '',
description: display?.description ?? '',
};
@@ -52,7 +50,7 @@ export function dtoToFormModel(
...common,
multiSelect: spec.allowMultiple ?? false,
showAllOption: spec.allowAllValue ?? false,
sort: (spec.sort as VariableSort) ?? VARIABLE_SORT_DISABLED,
sort: (spec.sort as VariableSort) ?? 'DISABLED',
defaultValue: spec.defaultValue,
};
const plugin = spec.plugin;
@@ -69,9 +67,7 @@ export function dtoToFormModel(
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
// An omitted wire signal means "all telemetry".
dynamicSignal:
(plugin.spec.signal as DynamicSignalOption) ?? DYNAMIC_SIGNAL_ALL,
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).
@@ -99,7 +95,7 @@ function buildPlugin(
kind: DynamicPluginKind['signoz/DynamicVariable'],
spec: {
name: model.dynamicAttribute,
signal: signalForApi(model.dynamicSignal),
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
},
};
case 'QUERY':
@@ -118,6 +114,7 @@ export function formModelToDto(
const display = {
name: model.name,
description: model.description,
hidden: model.hidden,
};
if (model.type === 'TEXT') {
@@ -138,10 +135,7 @@ export function formModelToDto(
name: model.name,
display,
allowMultiple: model.multiSelect,
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
// which forced showALLOption true on save); other types respect the toggle.
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
allowAllValue: model.showAllOption,
sort: model.sort,
defaultValue: model.defaultValue,
plugin: buildPlugin(model),
@@ -155,3 +149,5 @@ export function variableTypeOf(
): VariableFormModel['type'] {
return dtoToFormModel(dto).type;
}
export { PLUGIN_KIND };

View File

@@ -1,35 +0,0 @@
import {
buildDependencies,
buildDependencyGraph,
} from 'container/DashboardContainer/DashboardVariablesSelection/util';
import type { IDashboardVariable } from 'types/api/dashboard/getAll';
import type { VariableFormModel } from './variableFormModel';
/**
* Detects a circular reference among QUERY variables (a query referencing
* another that, transitively, references it back). Reuses the V1 dependency
* graph helpers, which key off `name` / `type` / `queryValue` only.
*
* Returns the names forming the cycle, or `null` when the set is acyclic.
*/
export function detectVariableCycle(
variables: VariableFormModel[],
): string[] | null {
const asDbVariables = variables
.filter((variable) => variable.name)
.map(
(variable) =>
({
name: variable.name,
type: variable.type,
queryValue: variable.queryValue,
}) as IDashboardVariable,
);
const { hasCycle, cycleNodes } = buildDependencyGraph(
buildDependencies(asDbVariables),
);
return hasCycle ? (cycleNodes ?? []) : null;
}

View File

@@ -1,154 +0,0 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
import { sortBy } from 'lodash-es';
/**
* The four variable types the editor exposes. No generated enum exists for this
* — it's a UI grouping over the wire's envelope + plugin kinds: the TextVariable
* envelope → `TEXT`, and a ListVariable's `DashboardtypesVariablePluginKindDTO`
* (`signoz/QueryVariable` | `signoz/CustomVariable` | `signoz/DynamicVariable`)
* → `QUERY` | `CUSTOM` | `DYNAMIC`. Replace with a generated enum if the backend
* ever exposes a single variable-kind type.
*/
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
/** Telemetry signal — the generated enum (traces / logs / metrics). */
export type TelemetrySignal = TelemetrytypesSignalDTO;
/**
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the
* generated `TelemetrytypesSignalDTO` has no "all") — it searches across every
* signal and maps to an omitted `signal` on the wire (see {@link signalForApi}).
*/
export const DYNAMIC_SIGNAL_ALL = 'all' as const;
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
/**
* Sort order for list-variable values. The wire (Perses) validates `sort`
* against a fixed method set. There is no generated TS enum for it
* (`DashboardtypesListOrderDTO` is the query-builder order, a different field),
* so we mirror the Perses `Sort` tokens here.
*/
export const VARIABLE_SORT = {
DISABLED: 'none',
ASC: 'alphabetical-asc',
DESC: 'alphabetical-desc',
NUMERICAL_ASC: 'numerical-asc',
NUMERICAL_DESC: 'numerical-desc',
CI_ASC: 'alphabetical-ci-asc',
CI_DESC: 'alphabetical-ci-desc',
} as const;
export type VariableSort = (typeof VARIABLE_SORT)[keyof typeof VARIABLE_SORT];
/** Persisted "no sort" value (Perses `none`). */
export const VARIABLE_SORT_DISABLED: VariableSort = VARIABLE_SORT.DISABLED;
export const VARIABLE_SORTS: VariableSort[] = [
VARIABLE_SORT.DISABLED,
VARIABLE_SORT.ASC,
VARIABLE_SORT.DESC,
VARIABLE_SORT.NUMERICAL_ASC,
VARIABLE_SORT.NUMERICAL_DESC,
VARIABLE_SORT.CI_ASC,
VARIABLE_SORT.CI_DESC,
];
export const VARIABLE_SORT_LABEL: Record<VariableSort, string> = {
[VARIABLE_SORT.DISABLED]: 'Disabled',
[VARIABLE_SORT.ASC]: 'Alphabetical (ascending)',
[VARIABLE_SORT.DESC]: 'Alphabetical (descending)',
[VARIABLE_SORT.NUMERICAL_ASC]: 'Numerical (ascending)',
[VARIABLE_SORT.NUMERICAL_DESC]: 'Numerical (descending)',
[VARIABLE_SORT.CI_ASC]: 'Alphabetical, case-insensitive (ascending)',
[VARIABLE_SORT.CI_DESC]: 'Alphabetical, case-insensitive (descending)',
};
export const DYNAMIC_SIGNALS: DynamicSignalOption[] = [
DYNAMIC_SIGNAL_ALL,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.metrics,
];
export const DYNAMIC_SIGNAL_LABEL: Record<DynamicSignalOption, string> = {
[DYNAMIC_SIGNAL_ALL]: 'All telemetry',
[TelemetrytypesSignalDTO.traces]: 'Traces',
[TelemetrytypesSignalDTO.logs]: 'Logs',
[TelemetrytypesSignalDTO.metrics]: 'Metrics',
};
/** Maps the editor's signal selection to the wire value (`'all'` → omitted). */
export function signalForApi(
signal: DynamicSignalOption,
): TelemetrySignal | undefined {
return signal === DYNAMIC_SIGNAL_ALL ? undefined : signal;
}
type SortableValues = (string | number | boolean)[];
/** Sorts preview / option values by the variable's chosen order (no-op when disabled). */
export function sortValuesByOrder(
values: SortableValues,
sort: VariableSort,
): SortableValues {
switch (sort) {
case VARIABLE_SORT.ASC:
return sortBy(values);
case VARIABLE_SORT.DESC:
return sortBy(values).reverse();
case VARIABLE_SORT.NUMERICAL_ASC:
return sortBy(values, (value) => Number(value));
case VARIABLE_SORT.NUMERICAL_DESC:
return sortBy(values, (value) => Number(value)).reverse();
case VARIABLE_SORT.CI_ASC:
return sortBy(values, (value) => String(value).toLowerCase());
case VARIABLE_SORT.CI_DESC:
return sortBy(values, (value) => String(value).toLowerCase()).reverse();
default:
return values;
}
}
export interface VariableFormModel {
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
name: string;
description: string;
type: VariableType;
// List-variable common fields (Query / Custom / Dynamic).
multiSelect: boolean;
showAllOption: boolean;
sort: VariableSort;
// Type-specific.
queryValue: string; // QUERY
customValue: string; // CUSTOM
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: DynamicSignalOption; // DYNAMIC — the telemetry signal
/**
* Runtime-selected default, not editable in the management tab yet; carried
* through edits so saving a definition doesn't clobber it.
*/
defaultValue?: VariableDefaultValueDTO;
}
export function emptyVariableFormModel(): VariableFormModel {
return {
name: '',
description: '',
type: 'DYNAMIC',
multiSelect: false,
showAllOption: false,
sort: VARIABLE_SORT_DISABLED,
queryValue: '',
customValue: '',
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: DYNAMIC_SIGNAL_ALL,
};
}

View File

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

View File

@@ -20,6 +20,8 @@ interface ConfigPaneProps {
/** The panel spec — the single editing surface (title/description + section slices). */
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Switch the panel to another visualization kind. */
onChangePanelKind: (kind: PanelKind) => 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. */
@@ -36,6 +38,7 @@ function ConfigPane({
panelKind,
spec,
onChangeSpec,
onChangePanelKind,
legendSeries,
tableColumns,
}: ConfigPaneProps): JSX.Element {
@@ -95,6 +98,8 @@ function ConfigPane({
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
/>
))}
</div>

View File

@@ -0,0 +1,6 @@
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -0,0 +1,54 @@
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import styles from './PanelTypeSwitcher.module.scss';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
panelKind: PanelKind;
/** Panel's current datasource — drives the disabled rule. */
signal?: TelemetrytypesSignalDTO;
onChange: (kind: PanelKind) => void;
}
/**
* Visualization-type selector (rendered inside the Visualization section). Types whose
* supported signals exclude the panel's current datasource are disabled (V1 parity —
* e.g. List needs logs/traces, not metrics). The datasource is unknown for
* PromQL/ClickHouse queries, in which case no type is disabled.
*/
function PanelTypeSwitcher({
panelKind,
signal,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = PANEL_TYPES.map((type) => {
const definition = getPanelDefinition(type.pluginKind as PanelKind);
return {
value: type.pluginKind,
label: type.label,
icon: type.icon,
disabled:
!!signal && !!definition && !definition.supportedSignals.includes(signal),
};
});
return (
<div className={styles.field}>
<Typography.Text>Panel Type</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-type-switcher"
value={panelKind}
items={items}
onChange={(value): void => onChange(value as PanelKind)}
/>
</div>
);
}
export default PanelTypeSwitcher;

View File

@@ -0,0 +1,73 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
function openDropdown(): void {
fireEvent.mouseDown(screen.getByRole('combobox'));
}
describe('PanelTypeSwitcher', () => {
beforeEach(() => {
jest.clearAllMocks();
// List supports only logs/traces; every other kind also supports metrics.
mockGetPanelDefinition.mockImplementation((kind: string) => ({
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
: ['metrics', 'logs', 'traces'],
}));
});
it('fires onChange with the chosen plugin kind', () => {
const onChange = jest.fn();
render(
<PanelTypeSwitcher panelKind="signoz/TimeSeriesPanel" onChange={onChange} />,
);
openDropdown();
fireEvent.click(screen.getByText('List'));
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
});
it('disables types whose supported signals exclude the current datasource', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
);
openDropdown();
const disabled = Array.from(
document.querySelectorAll('.ant-select-item-option-disabled'),
).map((el) => el.textContent);
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
expect(disabled).toContain('List');
expect(disabled).not.toContain('Time Series');
});
it('does not disable any type when the datasource is unknown', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
onChange={jest.fn()}
/>,
);
openDropdown();
expect(
document.querySelectorAll('.ant-select-item-option-disabled'),
).toHaveLength(0);
});
});

View File

@@ -8,6 +8,7 @@ import {
type SectionConfig,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { LegendSeries } from '../../hooks/useLegendSeries';
import type { TableColumnOption } from '../../hooks/useTableColumns';
import { resolveSectionEditor } from '../sectionRegistry';
@@ -23,6 +24,9 @@ interface SectionSlotProps {
tableColumns: TableColumnOption[];
/** Panel's telemetry signal, for editors that fetch field suggestions (List columns). */
signal?: TelemetrytypesSignalDTO;
/** Current panel kind + switch handler, for the visualization section's type switcher. */
panelKind: PanelKind;
onChangePanelKind: (kind: PanelKind) => void;
}
/**
@@ -38,6 +42,8 @@ function SectionSlot({
legendSeries,
tableColumns,
signal,
panelKind,
onChangePanelKind,
}: 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.
@@ -60,7 +66,12 @@ function SectionSlot({
.formatting?.unit;
return (
<SettingsSection title={title} icon={<Icon size={15} />}>
<SettingsSection
title={title}
icon={<Icon size={15} />}
// Open Visualization by default so the type switcher is visible.
defaultOpen={config.kind === 'visualization'}
>
<Component
value={get(spec)}
controls={controls}
@@ -69,6 +80,8 @@ function SectionSlot({
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
/>
</SettingsSection>
);

View File

@@ -22,6 +22,7 @@ function renderConfigPane(
panelKind: 'signoz/TimeSeriesPanel',
spec: spec(),
onChangeSpec: jest.fn(),
onChangePanelKind: jest.fn(),
legendSeries: [],
tableColumns: [],
...overrides,

View File

@@ -1,5 +1,5 @@
.group {
width: min(350px, 100%);
width: 100%;
}
.segment {

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import { Select } from 'antd';
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
@@ -7,7 +8,9 @@ import styles from './ConfigSelect.module.scss';
export interface ConfigSelectItem {
value: string;
label: string;
icon?: SegmentIconName;
/** A `SegmentIconName` string (resolved to a glyph), or an arbitrary icon node. */
icon?: ReactNode;
disabled?: boolean;
}
interface ConfigSelectProps {
@@ -40,9 +43,14 @@ function ConfigSelect({
virtual={false}
options={items.map((item) => ({
value: item.value,
disabled: item.disabled,
label: item.icon ? (
<span className={styles.item}>
<SegmentIcon name={item.icon} />
{typeof item.icon === 'string' ? (
<SegmentIcon name={item.icon as SegmentIconName} />
) : (
item.icon
)}
{item.label}
</span>
) : (

View File

@@ -157,6 +157,10 @@ export interface ErasedSectionDescriptor {
// The panel's telemetry signal; read by editors that fetch field-key
// suggestions scoped to it (List column picker).
signal?: unknown;
// Current panel kind + switch handler; read by the visualization section's
// type switcher.
panelKind?: unknown;
onChangePanelKind?: unknown;
}>;
get: (spec: PanelSpec) => unknown;
update: (spec: PanelSpec, value: unknown) => PanelSpec;

View File

@@ -9,8 +9,8 @@ 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.
// Stateful harness for flows that depend on the value updating (add/discard);
// omits `controls` to exercise the default `label` variant.
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<AnyThreshold[]>([]);
return (
@@ -33,7 +33,6 @@ describe('ThresholdsSection', () => {
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();
});
@@ -54,6 +53,21 @@ describe('ThresholdsSection', () => {
]);
});
it('persists an empty-string label when none is provided', () => {
const onChange = jest.fn();
// Label absent (e.g. a pre-existing spec); spec requires a string, so save
// must send '' not undefined.
const noLabel = [{ value: 50, color: '#F1575F' }] as AnyThreshold[];
render(<ThresholdsSection value={noLabel} onChange={onChange} />);
fireEvent.click(screen.getByTestId('threshold-edit-0'));
fireEvent.click(screen.getByTestId('threshold-save-0'));
expect(onChange).toHaveBeenCalledWith([
{ value: 50, color: '#F1575F', label: '' },
]);
});
it('does not commit edits when Discard is clicked', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
@@ -65,7 +79,6 @@ describe('ThresholdsSection', () => {
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();
});
@@ -83,11 +96,10 @@ describe('ThresholdsSection', () => {
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.
fireEvent.click(screen.getByTestId('threshold-discard-0'));
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
});

View File

@@ -23,10 +23,7 @@ interface LabelThresholdRowProps {
onRemove: () => void;
}
/**
* Value + color + label threshold (TimeSeries / Bar): a line drawn on the chart. Edit
* form is color, value, unit, label.
*/
/** Value + color + label threshold (TimeSeries / Bar): a line drawn on the chart. */
function LabelThresholdRow({
index,
threshold,
@@ -58,7 +55,7 @@ function LabelThresholdRow({
isEditing={isEditing}
summary={summary}
onEdit={onEdit}
onSave={(): void => onSave(draft)}
onSave={(): void => onSave({ ...draft, label: draft.label ?? '' })}
onDiscard={onDiscard}
onRemove={onRemove}
>

View File

@@ -1,26 +1,50 @@
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesTimePreferenceDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { PanelKind } from '../../../../Panels/types/panelKind';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
import styles from './VisualizationSection.module.scss';
type VisualizationSectionProps = SectionEditorProps<'visualization'> & {
/** Current panel kind + switch handler, forwarded by SectionSlot for the type switcher. */
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;
/** Panel's datasource, forwarded by SectionSlot — scopes the switcher's disabled types. */
signal?: TelemetrytypesSignalDTO;
};
/**
* 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.
* Edits the `visualization` slice: the panel-type switcher (`switchPanelKind`, every
* kind), the per-panel time preference, 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 fields its spec supports.
*/
function VisualizationSection({
value,
controls,
onChange,
}: SectionEditorProps<'visualization'>): JSX.Element {
panelKind,
onChangePanelKind,
signal,
}: VisualizationSectionProps): JSX.Element {
return (
<>
{controls.switchPanelKind && panelKind && onChangePanelKind && (
<PanelTypeSwitcher
panelKind={panelKind}
signal={signal}
onChange={onChangePanelKind}
/>
)}
{controls.timePreference && (
<div className={styles.field}>
<Typography.Text>Panel time preference</Typography.Text>

View File

@@ -4,6 +4,14 @@ import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.s
import VisualizationSection from '../VisualizationSection';
// The type switcher resolves each kind's supported signals; stub it so the test
// doesn't pull the whole panel registry (renderers, chart libs).
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
supportedSignals: ['metrics', 'logs', 'traces'],
})),
}));
// 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();
@@ -17,7 +25,12 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true, stacking: true, fillSpans: true }}
controls={{
switchPanelKind: true,
timePreference: true,
stacking: true,
fillSpans: true,
}}
onChange={jest.fn()}
/>,
);
@@ -35,7 +48,10 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true }}
controls={{
switchPanelKind: true,
timePreference: true,
}}
onChange={jest.fn()}
/>,
);
@@ -56,7 +72,10 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true }}
controls={{
switchPanelKind: true,
timePreference: true,
}}
onChange={onChange}
/>,
);
@@ -74,7 +93,10 @@ describe('VisualizationSection', () => {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
stackedBarChart: false,
}}
controls={{ stacking: true }}
controls={{
switchPanelKind: true,
stacking: true,
}}
onChange={onChange}
/>,
);
@@ -92,7 +114,10 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={{ fillSpans: false }}
controls={{ fillSpans: true }}
controls={{
switchPanelKind: true,
fillSpans: true,
}}
onChange={onChange}
/>,
);
@@ -101,4 +126,43 @@ describe('VisualizationSection', () => {
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
});
it('renders the type switcher and switches kind when switchPanelKind is set', async () => {
const onChangePanelKind = jest.fn();
render(
<VisualizationSection
value={undefined}
controls={{ switchPanelKind: true }}
onChange={jest.fn()}
panelKind="signoz/TimeSeriesPanel"
onChangePanelKind={onChangePanelKind}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-type-switcher'),
).toBeInTheDocument();
await pickOption('panel-editor-v2-type-switcher', 'Table');
expect(onChangePanelKind).toHaveBeenCalledWith('signoz/TablePanel');
});
it('hides the type switcher when switchPanelKind is not set', () => {
render(
<VisualizationSection
value={undefined}
controls={{
switchPanelKind: false,
timePreference: true,
}}
onChange={jest.fn()}
panelKind="signoz/TimeSeriesPanel"
onChangePanelKind={jest.fn()}
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-type-switcher'),
).not.toBeInTheDocument();
});
});

View File

@@ -1,14 +1,17 @@
import type {
DashboardtypesListPanelSpecDTO,
DashboardtypesPanelSpecDTO,
TelemetrytypesTelemetryFieldKeyDTO,
import {
type DashboardtypesListPanelSpecDTO,
type DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
type TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
} from 'container/OptionsMenu/constants';
/**
* The field-key suggestions API and the default-column constants carry extra
* runtime fields (e.g. `isIndexed`) that the save contract rejects. Reduce each
* column to the `TelemetrytypesTelemetryFieldKeyDTO` shape so persisted
* `selectFields` only contain backend-known keys.
* Reduce each column to the field-key DTO shape: the suggestions API and default
* constants carry extra runtime fields (e.g. `isIndexed`) the save contract rejects.
*/
function toFieldKeyDTO(
field: TelemetrytypesTelemetryFieldKeyDTO,
@@ -31,10 +34,26 @@ export function sanitizeSelectFields(
}
/**
* `spec.plugin.spec` is a discriminated union over every panel kind; these helpers
* run only for the List panel, so it's narrowed to the List variant with a single
* localized cast at the boundary.
* logs/traces List-column defaults (V1 parity), sanitized to the field-key DTO.
*/
export function defaultColumnsForSignal(
signal: TelemetrytypesSignalDTO,
): TelemetrytypesTelemetryFieldKeyDTO[] {
if (signal === TelemetrytypesSignalDTO.logs) {
return sanitizeSelectFields(
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
);
}
if (signal === TelemetrytypesSignalDTO.traces) {
return sanitizeSelectFields(
defaultTraceSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
);
}
return [];
}
// `spec.plugin.spec` is a discriminated union over panel kinds; these List-only
// helpers narrow to the List variant via a single localized cast at the boundary.
export function readSelectFields(
spec: DashboardtypesPanelSpecDTO,
): TelemetrytypesTelemetryFieldKeyDTO[] {

View File

@@ -0,0 +1,35 @@
import { Spline } from '@signozhq/icons';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
import { EQueryType } from 'types/common/dashboard';
interface PlotTagProps {
/** Authoring mode of the panel's query; undefined when no query exists yet. */
queryType: EQueryType | undefined;
panelType: PANEL_TYPES;
className?: string;
}
/**
* "Plotted with <query mode>" chip for the editor preview; V2 counterpart of V1's
* PlotTag (duplicated per the split policy). Hidden for list panels and before a
* query exists, where the mode is irrelevant.
*/
function PlotTag({
queryType,
panelType,
className,
}: PlotTagProps): JSX.Element | null {
if (queryType === undefined || panelType === PANEL_TYPES.LIST) {
return null;
}
return (
<div className={className} data-testid="panel-editor-plot-tag">
<Spline size={14} />
Plotted with <QueryTypeTag queryType={queryType} />
</div>
);
}
export default PlotTag;

View File

@@ -43,8 +43,10 @@
border-radius: 4px;
overflow: hidden;
display: flex;
// Header stacks above the body, flush to the border — mirrors the dashboard
// grid's `.panel` so the preview reads as the real panel chrome.
flex-direction: column;
background: var(--l2-background);
padding: 8px;
}
.state {
@@ -57,3 +59,7 @@
font-size: 13px;
text-align: center;
}
.dateTimeSelector {
margin-left: auto;
}

View File

@@ -1,25 +1,29 @@
import { Spline } from '@signozhq/icons';
import { useState } from 'react';
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 PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import type {
PanelPagination,
PanelQueryData,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { EQueryType } from 'types/common/dashboard';
import PlotTag from './PlotTag';
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;
/** Resolved definition for the panel kind; */
panelDefinition: RenderablePanelDefinition;
data: PanelQueryData;
isLoading: boolean;
/** Background refresh in flight — drives the header's subtle refetch spinner. */
isFetching: boolean;
error: Error | null;
/** Re-run the query (drives PanelBody's error-state retry). */
refetch: () => void;
@@ -30,50 +34,70 @@ interface PreviewPaneProps {
}
/**
* Live preview for the panel editor. Renders the draft through the same `PanelBody`
* the dashboard grid uses (only `panelMode={DASHBOARD_EDIT}` differs), so the preview
* is the production render path. The query result is owned by the editor root.
* Live preview for the panel editor: renders the draft through the same `PanelBody`
* the dashboard grid uses (only `panelMode` differs), so the preview is the
* production render path. The query result is owned by the editor root.
*/
function PreviewPane({
panelId,
panel,
panelDef,
panelDefinition,
data,
isLoading,
isFetching,
error,
refetch,
onDragSelect,
pagination,
}: PreviewPaneProps): JSX.Element {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const queryType = getPanelQueryType(panel);
// Search term is ephemeral preview state, threaded to header + renderer but
// not persisted to the draft spec. Only kinds that declare it render the box.
const searchable = !!panelDefinition.actions.search;
const [searchTerm, setSearchTerm] = useState('');
return (
<div className={styles.preview}>
<div className={styles.header}>
<div className={styles.queryType}>
<Spline size={14} />
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
<PlotTag
queryType={queryType}
panelType={panelType}
className={styles.queryType}
/>
<div className={styles.dateTimeSelector}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<div className={styles.container}>
<div className={styles.surface}>
{panelDef ? (
<PanelBody
panelDefinition={panelDef}
panel={panel}
panelId={panelId}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={PanelMode.DASHBOARD_EDIT}
pagination={pagination}
/>
) : (
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
This panel type is not yet supported in V2.
</div>
)}
<PanelHeader
name={panel.spec.display.name}
description={panel.spec.display?.description}
panelId={panelId}
panelKind={panel.spec.plugin.kind}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
hideActions
/>
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={PanelMode.DASHBOARD_EDIT}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import PlotTag from '../PlotTag';
describe('PlotTag', () => {
it('renders the resolved query mode', () => {
render(
<PlotTag queryType={EQueryType.PROM} panelType={PANEL_TYPES.TIME_SERIES} />,
);
expect(screen.getByTestId('panel-editor-plot-tag')).toBeInTheDocument();
expect(screen.getByText('PromQL')).toBeInTheDocument();
});
it('renders nothing when there is no query yet', () => {
render(<PlotTag queryType={undefined} panelType={PANEL_TYPES.TIME_SERIES} />);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
it('renders nothing for list panels (query mode is irrelevant)', () => {
render(
<PlotTag
queryType={EQueryType.QUERY_BUILDER}
panelType={PANEL_TYPES.LIST}
/>,
);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,90 @@
import {
TelemetrytypesSignalDTO,
type DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { defaultColumnsForSignal } from '../ListColumnsEditor/selectFields';
import { getSwitchedPluginSpec } from '../getSwitchedPluginSpec';
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
}));
jest.mock('../ListColumnsEditor/selectFields', () => ({
defaultColumnsForSignal: jest.fn(),
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
const mockDefaultColumnsForSignal =
defaultColumnsForSignal as unknown as jest.Mock;
function specWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Panel' },
plugin: { kind: 'signoz/TablePanel', spec: pluginSpec },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
describe('getSwitchedPluginSpec', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDefaultColumnsForSignal.mockReturnValue([]);
});
it('carries only unit + decimalPrecision when the new kind has a formatting section', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'formatting', controls: { unit: true, decimals: true } }],
});
const old = specWith({
formatting: { unit: 'ms', decimalPrecision: 2, columnUnits: { A: 'bytes' } },
axes: { logScale: true },
});
const result = getSwitchedPluginSpec(old, 'signoz/TimeSeriesPanel');
expect(result.formatting).toStrictEqual({ unit: 'ms', decimalPrecision: 2 });
// Type-specific config from the old kind is dropped.
expect((result as { axes?: unknown }).axes).toBeUndefined();
});
it('does not carry formatting when the new kind has no formatting section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({ formatting: { unit: 'ms' } });
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.formatting).toBeUndefined();
});
it('seeds List columns from the signal when switching into a List', () => {
const columns = [{ name: 'body' }];
mockDefaultColumnsForSignal.mockReturnValue(columns);
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const result = getSwitchedPluginSpec(
specWith({}),
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(mockDefaultColumnsForSignal).toHaveBeenCalledWith(
TelemetrytypesSignalDTO.logs,
);
expect(result.selectFields).toBe(columns);
});
it('includes the kind section defaults (e.g. legend position)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'legend', controls: { position: true } }],
});
const result = getSwitchedPluginSpec(specWith({}), 'signoz/PieChartPanel');
expect(result.legend?.position).toBe('bottom');
});
});

View File

@@ -0,0 +1,34 @@
import {
NEW_PANEL_ID,
newPanelSearch,
parseNewPanelKind,
parseNewPanelLayoutIndex,
} from '../newPanelRoute';
describe('newPanelRoute', () => {
it('round-trips kind + layoutIndex through the new-panel search', () => {
const search = newPanelSearch('signoz/ListPanel', 2);
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe('signoz/ListPanel');
expect(parseNewPanelLayoutIndex(search)).toBe(2);
});
it('omits layoutIndex when not provided', () => {
const search = newPanelSearch('signoz/TimeSeriesPanel');
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe(
'signoz/TimeSeriesPanel',
);
expect(parseNewPanelLayoutIndex(search)).toBeUndefined();
});
it('returns null for an existing panel id (not the new sentinel)', () => {
const search = newPanelSearch('signoz/ListPanel');
expect(parseNewPanelKind('a1b2c3d4-uuid', search)).toBeNull();
});
it('returns null when the kind param is missing or unknown', () => {
expect(parseNewPanelKind(NEW_PANEL_ID, '')).toBeNull();
expect(
parseNewPanelKind(NEW_PANEL_ID, '?panelKind=NotARealPanel'),
).toBeNull();
});
});

View File

@@ -0,0 +1,67 @@
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import type { PanelFormattingSlice } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import {
buildDefaultPluginSpec,
type DefaultPluginSpec,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildDefaultPluginSpec';
import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
/**
* Plugin spec produced on a first-time switch to a new kind. A partial cross-section
* of the per-kind spec union; the caller assigns it to `plugin.spec` (typed `unknown`)
* at the boundary.
*/
export interface SwitchedPluginSpec extends DefaultPluginSpec {
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
}
/**
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
* the only cross-kind config worth keeping — unit + decimal precision. Switching into a
* List seeds the current signal's default columns so the columns control isn't empty.
*
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
*/
export function getSwitchedPluginSpec(
oldSpec: DashboardtypesPanelSpecDTO,
newKind: PanelKind,
signal?: TelemetrytypesSignalDTO,
): SwitchedPluginSpec {
const sections = getPanelDefinition(newKind)?.sections ?? [];
const result: SwitchedPluginSpec = buildDefaultPluginSpec(sections);
if (sections.some((section) => section.kind === 'formatting')) {
const oldFormatting = (
oldSpec.plugin.spec as {
formatting?: PanelFormattingSlice;
}
).formatting;
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
...(oldFormatting?.unit !== undefined && { unit: oldFormatting.unit }),
...(oldFormatting?.decimalPrecision !== undefined && {
decimalPrecision: oldFormatting.decimalPrecision,
}),
};
if (Object.keys(carried).length > 0) {
result.formatting = carried;
}
}
if (sections.some((section) => section.kind === 'columns') && signal) {
const columns = defaultColumnsForSignal(signal);
if (columns.length > 0) {
result.selectFields = columns;
}
}
return result;
}

View File

@@ -0,0 +1,190 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { handleQueryChange } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
import { usePanelTypeSwitch } from '../usePanelTypeSwitch';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('container/NewWidget/utils', () => ({
handleQueryChange: jest.fn(),
PANEL_TYPE_TO_QUERY_TYPES: {
graph: ['builder', 'clickhouse', 'promql'],
table: ['builder', 'clickhouse'],
list: ['builder'],
value: ['builder', 'clickhouse', 'promql'],
bar: ['builder', 'clickhouse', 'promql'],
pie: ['builder', 'clickhouse'],
histogram: ['builder', 'clickhouse', 'promql'],
},
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),
}));
jest.mock('../../getSwitchedPluginSpec', () => ({
getSwitchedPluginSpec: jest.fn(),
}));
jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
getBuilderQueries: jest.fn(),
}));
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
// Opaque sentinels — the leaf utilities are mocked, so only identity matters.
const TABLE_PLUGIN_SPEC = { table: true } as unknown;
const TABLE_QUERIES = [{ id: 'table-q' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const LIST_PLUGIN_SPEC = { list: true } as unknown;
const LIST_QUERIES = [{ id: 'list-q' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const TRANSFORMED = {
id: 'transformed',
queryType: 'builder',
} as unknown as Query;
const CONVERTED = [{ id: 'converted' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const SWITCHED_SPEC = { switched: true } as unknown;
function makeSpec(
kind: string,
pluginSpec: unknown,
queries: NonNullable<DashboardtypesPanelSpecDTO['queries']>,
): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Panel' },
plugin: { kind, spec: pluginSpec },
queries,
} as unknown as DashboardtypesPanelSpecDTO;
}
const tableSpec = makeSpec(
'signoz/TablePanel',
TABLE_PLUGIN_SPEC,
TABLE_QUERIES,
);
const listSpec = makeSpec('signoz/ListPanel', LIST_PLUGIN_SPEC, LIST_QUERIES);
function builderState(currentQuery: Query): {
currentQuery: Query;
redirectWithQueryBuilderData: jest.Mock;
} {
return { currentQuery, redirectWithQueryBuilderData: jest.fn() };
}
describe('usePanelTypeSwitch', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
mockToPerses.mockReturnValue(CONVERTED);
mockGetSwitchedPluginSpec.mockReturnValue(SWITCHED_SPEC);
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
});
it('does nothing when switching to the current kind', () => {
const setSpec = jest.fn();
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
expect(setSpec).not.toHaveBeenCalled();
expect(state.redirectWithQueryBuilderData).not.toHaveBeenCalled();
});
it('on first visit: transforms the query and resets the spec to the new kind', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const state = builderState(tableQuery);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
expect(setSpec).toHaveBeenCalledTimes(1);
const next = setSpec.mock.calls[0][0] as DashboardtypesPanelSpecDTO;
expect(next.plugin.kind).toBe('signoz/ListPanel');
expect(next.plugin.spec).toBe(SWITCHED_SPEC);
expect(next.queries).toBe(CONVERTED);
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(TRANSFORMED);
});
it('coerces the query type when the new kind disallows it (promql → List)', () => {
const setSpec = jest.fn();
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// List allows only Query Builder, so the promql query is coerced to 'builder'.
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
expect((queryArg as Query).queryType).toBe('builder');
});
it('restores the original kind verbatim on switch-back (reversibility)', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
let state = builderState(tableQuery);
mockUseQueryBuilder.mockImplementation(() => state);
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelTypeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);
// Leave Table for List (stashes Table in its pristine state).
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// Parent re-renders as a List panel; the builder now holds the List query.
state = builderState(listQuery);
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
// Switch back to Table → restored from the stash, not re-transformed.
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const restored = setSpec.mock.calls[
setSpec.mock.calls.length - 1
][0] as DashboardtypesPanelSpecDTO;
expect(restored.plugin.kind).toBe('signoz/TablePanel');
expect(restored.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
expect(restored.queries).toBe(TABLE_QUERIES);
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(tableQuery);
// The restore path must not run the query transform again.
expect(mockHandleQueryChange).toHaveBeenCalledTimes(1);
});
});

View File

@@ -12,8 +12,8 @@ import {
import { sanitizeSelectFields } from '../../ListColumnsEditor/selectFields';
import { useSwitchColumnsOnSignalChange } from '../useSwitchColumnsOnSignalChange';
// The hook applies the datasource defaults reduced to the field-key DTO (the V1
// constants carry extra keys like `isIndexed`); assertions mirror that.
// V1 constants carry extra keys (e.g. `isIndexed`); the hook reduces them to the
// field-key DTO, so assertions sanitize the same way.
const expectedLogs = sanitizeSelectFields(
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
);
@@ -73,6 +73,45 @@ describe('useSwitchColumnsOnSignalChange', () => {
);
});
it('restores the original columns on logs → traces → logs', () => {
// Customized logs selection, not the timestamp/body defaults.
const original = [
{ name: 'timestamp' },
{ name: 'body' },
{ name: 'response_status_code' },
{ name: 'trace_id' },
];
// Mirror the real parent: persist the spec so the next switch stashes the
// columns the previous one applied.
let spec = makeSpec(original);
const onChangeSpec = jest.fn((next: DashboardtypesPanelSpecDTO) => {
spec = next;
});
const { rerender } = renderWith({
enabled: true,
signal: TelemetrytypesSignalDTO.logs,
spec,
onChangeSpec,
});
rerender({
enabled: true,
signal: TelemetrytypesSignalDTO.traces,
spec,
onChangeSpec,
});
expect(selectFieldsOf(spec)).toStrictEqual(expectedTraces);
// Switching back restores the original columns, not the log defaults.
rerender({
enabled: true,
signal: TelemetrytypesSignalDTO.logs,
spec,
onChangeSpec,
});
expect(selectFieldsOf(spec)).toStrictEqual(original);
});
it('switches to the log defaults when going traces → logs', () => {
const onChangeSpec = jest.fn();
const spec = makeSpec([{ name: 'service.name' }]);

View File

@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { getIsQueryModified } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
@@ -19,14 +20,24 @@ interface UsePanelEditorQuerySyncArgs {
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Re-fetch the preview when the query is unchanged (Stage & Run on a no-op). */
refetch: () => void;
/**
* Serialize the live query on save even when unchanged. Set for a new panel,
* whose seed query is the builder default (not a real saved query).
*/
alwaysSerializeQuery?: boolean;
/**
* Datasource to seed a new panel's builder with — the panel kind's first
* supported signal (e.g. List supports only logs/traces, not metrics).
*/
defaultDataSource?: TelemetrytypesSignalDTO;
}
interface UsePanelEditorQuerySyncApi {
/** Run the current query (Stage & Run / ⌘↵). */
runQuery: () => void;
/** True when the live builder query differs from the saved query (compared builder-normalized to avoid re-serialization noise). */
/** True when the live builder query differs from the saved query. */
isQueryDirty: boolean;
/** Bake the live query into a spec for saving so unstaged edits persist; returns the spec untouched when unchanged. */
/** Bake the live query into a spec so unstaged edits persist; unchanged → spec untouched. */
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
@@ -34,27 +45,36 @@ interface UsePanelEditorQuerySyncApi {
/**
* Bridges the shared (URL-synced) query builder 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/datasource switch and on Stage & Run.
* builder from the saved panel, then commits the active query into
* `draft.spec.queries` (what the preview fetches) on a query-type/datasource switch
* and on Stage & Run.
*/
export function usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
alwaysSerializeQuery = false,
defaultDataSource,
}: 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 ?? [], []);
// A new panel has no saved query: seed from the kind's first supported signal
// instead of letting `fromPerses` fall back to the metrics default (which List
// doesn't support).
const seedQuery = useMemo(
() => fromPerses(savedQueries, panelType),
[savedQueries, panelType],
() =>
savedQueries.length === 0 && defaultDataSource
? initialQueriesMap[defaultDataSource]
: fromPerses(savedQueries, panelType),
[savedQueries, panelType, defaultDataSource],
);
// Force-reset the builder to the SAVED panel on first render only, discarding any
// stale URL query from a prior edit — otherwise the QB and preview diverge and the
// dirty baseline gets captured from the URL. After mount the URL syncs normally.
// Force-reset the builder to the SAVED panel on first render only, discarding a
// stale URL query from a prior edit (else the QB/preview diverge and the dirty
// baseline is captured from the URL). After mount the URL syncs normally.
const isInitialRenderRef = useRef(true);
useShareBuilderUrl({
defaultValue: seedQuery,
@@ -64,11 +84,10 @@ export function usePanelEditorQuerySync({
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,
// which can carry stale state across a refresh and make a real switch read as
// "unchanged". Unchanged → restore saved queries; changed → commit. Returns whether
// the draft changed.
// 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, which can carry stale state across a refresh and read a real
// switch as "unchanged". Returns whether the draft changed.
const commitQuery = useCallback(
(query: Query): boolean => {
const next = getIsQueryModified(query, seedQuery)
@@ -76,7 +95,7 @@ export function usePanelEditorQuerySync({
: savedQueries;
// No-op guard at the V5 envelope level: equivalent wrappers (bare
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
// envelopes, so comparing them structurally would falsely dirty the draft.
// envelopes, so a structural compare would falsely dirty the draft.
const current = draft.spec?.queries ?? [];
if (isEqual(toQueryEnvelopes(next), toQueryEnvelopes(current))) {
return false;
@@ -93,8 +112,8 @@ export function usePanelEditorQuerySync({
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
// Re-commit on a query-type or datasource switch so the preview refetches. Skip
// mount: the draft already holds the saved queries the builder is force-reset to.
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
// mount: the draft already holds the saved queries the builder is reset to.
const dataSourceSignature = useMemo(
() =>
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
@@ -119,10 +138,10 @@ export function usePanelEditorQuerySync({
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
// Dirty baseline: the builder's OWN normalized saved query (first non-null
// `stagedQuery` after the mount reset). Comparing builder-normalized to
// `stagedQuery` after the mount reset) — comparing builder-normalized to
// builder-normalized avoids serialization drift reading an untouched query as
// modified. Held in state (not a ref) so capture re-triggers `isQueryDirty`;
// captured once and never moved by Stage & Run, so it stays anchored to saved.
// modified. In state (not a ref) so capture re-triggers `isQueryDirty`; captured
// once and never moved by Stage & Run, so it stays anchored to saved.
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
useEffect(() => {
if (queryBaseline === null && stagedQuery) {
@@ -135,10 +154,10 @@ export function usePanelEditorQuerySync({
const buildSaveSpec = useCallback(
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
isQueryDirty
isQueryDirty || alwaysSerializeQuery
? { ...spec, queries: toPerses(currentQuery, panelType) }
: spec,
[isQueryDirty, currentQuery, panelType],
[isQueryDirty, alwaysSerializeQuery, currentQuery, panelType],
);
return { runQuery, isQueryDirty, buildSaveSpec };

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { v4 as uuid } from 'uuid';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
@@ -7,12 +8,20 @@ import {
import {
type DashboardtypesJSONPatchOperationDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesPanelKindDTO,
DashboardtypesPatchOpDTO,
type GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { createPanelOps } from '../../patchOps';
interface UsePanelEditorSaveArgs {
dashboardId: string;
panelId: string;
/** Creating a new panel (vs editing an existing one) — adds panel + layout. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
}
interface UsePanelEditorSaveApi {
@@ -22,34 +31,49 @@ interface UsePanelEditorSaveApi {
}
/**
* Persists panel edits via a single RFC-6902 `add` op that replaces the whole panel
* spec at `/spec/panels/{panelId}/spec`, so every config-pane edit is saved (not just
* title/description). `add` doubles as create-or-replace, avoiding a separate
* existence check.
* Persists panel edits for the V2 editor via RFC-6902 JSON Patch. Editing: one
* `add` op replaces the whole spec. Creating (`isNew`): mints a fresh id and adds
* a grid item in the target section. Persists only on save — cancelling never
* touches the dashboard.
*/
export function usePanelEditorSave({
dashboardId,
panelId,
isNew = false,
layoutIndex,
}: 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,
},
];
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
let ops: DashboardtypesJSONPatchOperationDTO[];
if (isNew) {
// Resolve the target section against the freshest dashboard we have.
const cached =
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
ops = createPanelOps({
layouts: cached?.data.spec.layouts ?? [],
layoutIndex,
panelId: uuid(),
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
});
} else {
ops = [
{
op: DashboardtypesPatchOpDTO.add,
path: `/spec/panels/${panelId}/spec`,
value: spec,
},
];
}
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
await queryClient.invalidateQueries(
getGetDashboardV2QueryKey({ id: dashboardId }),
);
await queryClient.invalidateQueries(dashboardQueryKey);
},
[dashboardId, panelId, mutateAsync, queryClient],
[dashboardId, panelId, isNew, layoutIndex, mutateAsync, queryClient],
);
return { save, isSaving: isLoading, error: (error as Error) ?? null };

View File

@@ -0,0 +1,130 @@
import { useCallback, useRef } from 'react';
import type {
DashboardtypesPanelSpecDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
handleQueryChange,
PANEL_TYPE_TO_QUERY_TYPES,
type PartialPanelTypes,
} from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../getSwitchedPluginSpec';
/** What a kind looks like when you leave it; restored verbatim if you return. */
interface KindState {
pluginSpec: unknown;
queries: DashboardtypesQueryDTO[] | null;
builderQuery: Query;
}
interface UsePanelTypeSwitchArgs {
spec: DashboardtypesPanelSpecDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
interface UsePanelTypeSwitchApi {
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
onChangePanelKind: (newKind: PanelKind) => void;
}
/**
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
* renderer, config sections, query-builder tabs and request type for free; this hook adds
* the two things that don't: a per-kind session cache that makes switching reversible
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
*/
export function usePanelTypeSwitch({
spec,
panelType,
setSpec,
}: UsePanelTypeSwitchArgs): UsePanelTypeSwitchApi {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
// Latest spec/query/type, read inside the stable callback without re-subscribing.
const specRef = useRef(spec);
specRef.current = spec;
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
const panelTypeRef = useRef(panelType);
panelTypeRef.current = panelType;
const onChangePanelKind = useCallback(
(newKind: PanelKind): void => {
const currentSpec = specRef.current;
const oldKind = currentSpec.plugin.kind as PanelKind;
if (newKind === oldKind) {
return;
}
const query = queryRef.current;
cacheRef.current.set(oldKind, {
pluginSpec: currentSpec.plugin.spec,
queries: currentSpec.queries ?? null,
builderQuery: query,
});
const newPanelType = PANEL_KIND_TO_PANEL_TYPE[newKind];
// `plugin` is a discriminated union over `kind`; a dynamically-chosen kind
// can't be expressed in the static union, so build the new spec and cast at
// this boundary (as `createDefaultPanel` does).
const buildSpec = (
pluginSpec: unknown,
queries: DashboardtypesQueryDTO[] | null,
): DashboardtypesPanelSpecDTO =>
({
...currentSpec,
plugin: { ...currentSpec.plugin, kind: newKind, spec: pluginSpec },
queries,
}) as unknown as DashboardtypesPanelSpecDTO;
// Revisit → restore the stash verbatim (the reversibility path).
const cached = cacheRef.current.get(newKind);
if (cached) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
redirectWithQueryBuilderData(cached.builderQuery);
return;
}
// First visit → coerce the query type if the new panel disallows it, then
// rebuild the builder query for the new type.
const supported = PANEL_TYPE_TO_QUERY_TYPES[newPanelType] ?? [];
const queryType = supported.includes(query.queryType)
? query.queryType
: supported[0];
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...query, queryType },
panelTypeRef.current,
);
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(
buildSpec(
getSwitchedPluginSpec(currentSpec, newKind, signal),
toPerses(transformed, newPanelType),
),
);
redirectWithQueryBuilderData(transformed);
},
[setSpec, redirectWithQueryBuilderData],
);
return { onChangePanelKind };
}

View File

@@ -0,0 +1,47 @@
import { useEffect, useRef } from 'react';
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
defaultColumnsForSignal,
readSelectFields,
writeSelectFields,
} from '../ListColumnsEditor/selectFields';
interface UseSeedNewListColumnsArgs {
/** Gate: a brand-new List panel (the only case that should auto-fill columns). */
enabled: boolean;
/** Default signal for the new panel — its kind's first supported signal. */
signal: TelemetrytypesSignalDTO;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
/**
* Seeds a brand-new List panel's columns with its default signal's columns so the
* Columns control isn't empty on first open. Runs once and only when empty: an
* empty selection is a valid "show all fields" state, so existing panels and
* user-cleared selections are never touched.
*/
export function useSeedNewListColumns({
enabled,
signal,
spec,
onChangeSpec,
}: UseSeedNewListColumnsArgs): void {
const seededRef = useRef(false);
useEffect(() => {
if (!enabled || seededRef.current || !signal) {
return;
}
// Only seed when empty — don't clobber a selection that's already present.
if (readSelectFields(spec).length > 0) {
return;
}
seededRef.current = true;
onChangeSpec(writeSelectFields(spec, defaultColumnsForSignal(signal)));
}, [enabled, signal, spec, onChangeSpec]);
}

View File

@@ -1,36 +1,16 @@
import { useEffect, useRef } from 'react';
import {
type DashboardtypesPanelSpecDTO,
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
type TelemetrytypesTelemetryFieldKeyDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
} from 'container/OptionsMenu/constants';
import { sanitizeSelectFields } from '../ListColumnsEditor/selectFields';
/**
* The datasource's default List columns (V1 parity), sanitized to the field-key
* DTO — the V1 constants carry extra keys (isIndexed) the save contract rejects.
* Other signals (metrics) don't produce a list, so they clear the selection.
*/
function defaultColumnsForSignal(
signal: TelemetrytypesSignalDTO,
): TelemetrytypesTelemetryFieldKeyDTO[] {
if (signal === TelemetrytypesSignalDTO.logs) {
return sanitizeSelectFields(
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
);
}
if (signal === TelemetrytypesSignalDTO.traces) {
return sanitizeSelectFields(
defaultTraceSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
);
}
return [];
}
defaultColumnsForSignal,
readSelectFields,
writeSelectFields,
} from '../ListColumnsEditor/selectFields';
interface UseSwitchColumnsOnSignalChangeArgs {
/** Gate so the switch only runs for the List kind (the only one with columns). */
@@ -42,11 +22,10 @@ interface UseSwitchColumnsOnSignalChangeArgs {
}
/**
* Switches the List panel's chosen columns to the new datasource's defaults when
* the panel's telemetry signal changes (e.g. logs → traces). V1 kept a separate
* field list per datasource; V2 stores a single `selectFields`, so columns picked
* for one signal are meaningless after switching — replace them with the new
* source's sensible defaults (matching V1's logs/traces list defaults).
* Swaps the List panel's columns when the telemetry signal changes. V2 stores a
* single `selectFields`, so each signal's columns are stashed and restored on
* switch-back; a signal seen for the first time gets the datasource defaults (V1
* parity).
*/
export function useSwitchColumnsOnSignalChange({
enabled,
@@ -55,28 +34,29 @@ export function useSwitchColumnsOnSignalChange({
onChangeSpec,
}: UseSwitchColumnsOnSignalChangeArgs): void {
const prevSignalRef = useRef(signal);
const columnsBySignalRef = useRef<
Map<string, TelemetrytypesTelemetryFieldKeyDTO[]>
>(new Map());
useEffect(() => {
const prev = prevSignalRef.current;
if (!enabled || !signal) {
return;
}
// Track only real signals: a transient `undefined` (mid query-edit) must
// not become `prev`, or stash/restore would lose a step.
prevSignalRef.current = signal;
if (!enabled) {
if (!prev || prev === signal) {
return;
}
// Only an actual switch between two known signals swaps the columns;
// transient `undefined` states (mid query-edit) leave the selection intact.
if (!prev || !signal || prev === signal) {
return;
}
onChangeSpec({
...spec,
plugin: {
...spec.plugin,
spec: {
...spec.plugin.spec,
selectFields: defaultColumnsForSignal(signal),
},
},
} as DashboardtypesPanelSpecDTO);
// Stash the leaving signal's columns; restore the entering one's, or its
// datasource defaults the first time it's seen.
columnsBySignalRef.current.set(prev, readSelectFields(spec));
const restored =
columnsBySignalRef.current.get(signal) ?? defaultColumnsForSignal(signal);
onChangeSpec(writeSelectFields(spec, restored));
}, [enabled, signal, spec, onChangeSpec]);
}

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import {
ResizableHandle,
ResizablePanel,
@@ -6,7 +6,7 @@ import {
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import type {
import {
DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -29,6 +29,8 @@ import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useTableColumns } from './hooks/useTableColumns';
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
@@ -39,6 +41,10 @@ interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
@@ -46,19 +52,26 @@ interface PanelEditorContainerProps {
}
/**
* V2 panel editor page body (rendered full-page by `PanelEditorPage`): a resizable
* split with the live preview + query builder on the left and the config pane on the
* right. Owns the draft state and the save round-trip.
* V2 panel editor page body: a resizable split with the live preview + query
* builder on the left and the config pane on the right. Owns the draft state and
* the save round-trip.
*/
function PanelEditorContainer({
dashboardId,
panelId,
panel,
isNew = false,
layoutIndex,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
@@ -79,7 +92,7 @@ function PanelEditorContainer({
PANEL_TYPES.TIME_SERIES;
// One shared query result for the whole editor; the preview renders it.
const panelDef = getPanelDefinition(draft.spec.plugin.kind);
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
const {
data,
isLoading,
@@ -91,23 +104,33 @@ function PanelEditorContainer({
} = usePanelQuery({
panel: draft,
panelId,
enabled: !!panelDef,
enabled: !!panelDefinition,
});
// Seed the shared query builder from the draft and expose the Stage-&-Run action.
// A new panel defaults to its kind's first supported signal (e.g. List → logs);
// drives both the seed query's datasource and the seed of its default columns.
const defaultDataSource = useMemo(
() => panelDefinition?.supportedSignals[0] || TelemetrytypesSignalDTO.metrics,
[panelDefinition],
);
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
// New panel's seed query is the builder default, not a real saved query —
// always serialize it on save.
alwaysSerializeQuery: isNew,
defaultDataSource,
});
// Switch the panel's visualization kind in place (reversible per session).
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
// Spec and query dirtiness are tracked independently so query re-serialization
// never false-dirties.
const isDirty = isSpecDirty || isQueryDirty;
// The List panel edits its columns below the query builder (V1 parity), so the
// editor container resolves the committed query's signal once and shares it
// with both the columns control and the datasource-switch effect below.
// never false-dirties. A new panel is always savable (you're creating it).
const isDirty = isNew || isSpecDirty || isQueryDirty;
const isListPanel = fullKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
@@ -116,18 +139,24 @@ function PanelEditorContainer({
| TelemetrytypesSignalDTO
| undefined;
// When the List panel's datasource changes, swap its columns to the new
// source's defaults (V1 kept a per-datasource field list; V2 has one
// `selectFields`). Driven by the committed query's signal, so it lives in the
// editor container alongside the query sync — ConfigPane stays presentational.
// Swap the List panel's columns to the new source's defaults on a datasource
// change (V1 kept a per-datasource field list; V2 has one `selectFields`).
useSwitchColumnsOnSignalChange({
enabled: isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
// A brand-new List panel starts with no columns; seed the default signal's
// columns once so the Columns control isn't empty on first open (V1 parity).
useSeedNewListColumns({
enabled: isNew && isListPanel,
signal: defaultDataSource,
spec,
onChangeSpec: setSpec,
});
// Drag-to-zoom on the preview chart updates the URL-synced time window, as on
// the dashboard.
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
@@ -166,17 +195,20 @@ function PanelEditorContainer({
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
<PreviewPane
panelId={panelId}
panel={draft}
panelDef={panelDef}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
{panelDefinition && (
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isLoading={isLoading || isFetching}
isFetching={isFetching}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
)}
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
@@ -210,6 +242,7 @@ function PanelEditorContainer({
panelKind={draft.spec.plugin.kind}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
legendSeries={legendSeries}
tableColumns={tableColumns}
/>

View File

@@ -0,0 +1,48 @@
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../Panels/types/panelKind';
// New (unsaved) panels share a fixed id segment, carrying kind + target section
// in the query: `/panel/new?panelKind=signoz/ListPanel&layoutIndex=2`. The real
// id is generated on save.
export const NEW_PANEL_ID = 'new';
const PANEL_KIND_PARAM = 'panelKind';
const LAYOUT_INDEX_PARAM = 'layoutIndex';
/** Query string (incl. leading `?`) for the new-panel editor route. */
export function newPanelSearch(
pluginKind: PanelKind,
layoutIndex?: number,
): string {
const params = new URLSearchParams({ [PANEL_KIND_PARAM]: pluginKind });
if (layoutIndex !== undefined) {
params.set(LAYOUT_INDEX_PARAM, String(layoutIndex));
}
return `?${params.toString()}`;
}
/**
* The PanelKind a `panel/new` route is creating, or null when the id isn't the
* new-panel sentinel or the `panelKind` param is missing/unknown (stale link).
*/
export function parseNewPanelKind(
panelId: string,
search: string,
): PanelKind | null {
if (panelId !== NEW_PANEL_ID) {
return null;
}
const kind = new URLSearchParams(search).get(PANEL_KIND_PARAM);
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
}
/** Target section index for a new panel, or undefined when unset/invalid. */
export function parseNewPanelLayoutIndex(search: string): number | undefined {
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);
if (raw === null || raw === '') {
return undefined;
}
const n = Number(raw);
return Number.isNaN(n) ? undefined : n;
}

View File

@@ -1,11 +0,0 @@
.noData {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.noDataText {
font-size: 14px;
}

View File

@@ -1,27 +1,39 @@
import { Typography } from '@signozhq/ui/typography';
import { Clock, RotateCw } from '@signozhq/icons';
import styles from './NoData.module.scss';
import PanelMessage from '../PanelMessage/PanelMessage';
interface NoDataProps {
/** Message to display. Defaults to "No data". */
label?: string;
/** Title override. Defaults to the time-range empty-state copy. */
title?: string;
/** Description override. Defaults to the "widen the range" hint. */
description?: string;
/** When provided, renders a Retry button that re-runs the query. */
onRetry?: () => void;
'data-testid'?: string;
}
/**
* Shared empty-state for panel renderers, shown when a query resolves but
* returns nothing to plot. Centred in the panel body so every panel kind
* surfaces the same "No data" affordance instead of each renderer (or its
* underlying chart) inventing its own copy and casing.
* Shared empty-state for panel renderers: wraps `PanelMessage` so every panel
* kind surfaces the same "no data" affordance when a query returns nothing.
*/
function NoData({
label = 'No data',
title = 'No data in this time range',
description = 'Nothing in the selected window. Try widening the range.',
onRetry,
'data-testid': testId = 'panel-no-data',
}: NoDataProps): JSX.Element {
return (
<div className={styles.noData} data-testid={testId}>
<Typography.Text className={styles.noDataText}>{label}</Typography.Text>
</div>
<PanelMessage
icon={<Clock size={18} />}
title={title}
description={description}
action={
onRetry
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
: undefined
}
data-testid={testId}
/>
);
}

View File

@@ -0,0 +1,50 @@
// Centred, vertically-stacked panel state (no query / no data / error). Fills
// the panel body below the header and centres its content both axes.
.message {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 16px;
text-align: center;
min-height: 0;
min-width: 0;
}
// Muted glyph in a soft tinted disc so the icon reads as decorative chrome
// rather than an actionable control.
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin-bottom: 4px;
border-radius: 8px;
color: var(--l2-foreground);
background: var(--l2-background);
}
.iconDanger {
color: var(--bg-cherry-500);
background: var(--bg-cherry-500-transparent, rgba(231, 64, 64, 0.12));
}
.title {
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
}
.description {
font-size: 12px;
color: var(--l2-foreground);
max-width: 280px;
overflow-wrap: anywhere;
}
.action {
margin-top: 8px;
}

View File

@@ -0,0 +1,68 @@
import type { ReactElement, ReactNode } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './PanelMessage.module.scss';
export interface PanelMessageAction {
label: string;
onClick: () => void;
/** Optional leading icon for the action button. */
icon?: ReactElement;
}
interface PanelMessageProps {
/** Glyph shown above the title — sets the state's visual identity. */
icon: ReactNode;
title: string;
/** Secondary line explaining the state / suggesting a next step. */
description?: string;
/** Optional call-to-action (e.g. Retry). Omitted → no button. */
action?: PanelMessageAction;
/** `danger` tints the icon for failure states; `neutral` for empty states. */
tone?: 'neutral' | 'danger';
'data-testid'?: string;
}
/**
* Shared centred panel state (icon + title + optional description/action) so the
* no-query / no-data / error states stay visually consistent across call sites.
*/
function PanelMessage({
icon,
title,
description,
action,
tone = 'neutral',
'data-testid': testId,
}: PanelMessageProps): JSX.Element {
return (
<div className={styles.message} data-testid={testId}>
<div className={cx(styles.icon, { [styles.iconDanger]: tone === 'danger' })}>
{icon}
</div>
<Typography.Text className={styles.title}>{title}</Typography.Text>
{description && (
<Typography.Text className={styles.description}>
{description}
</Typography.Text>
)}
{action && (
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={action.icon}
onClick={action.onClick}
className={styles.action}
data-testid={testId ? `${testId}-action` : undefined}
>
{action.label}
</Button>
)}
</div>
);
}
export default PanelMessage;

View File

@@ -0,0 +1,46 @@
import { fireEvent, render, screen } from '@testing-library/react';
import PanelMessage from '../PanelMessage';
describe('PanelMessage', () => {
it('renders the icon, title and description', () => {
render(
<PanelMessage
icon={<svg data-testid="icon" />}
title="Nothing to visualize yet"
description="This panel has no query."
data-testid="panel-state"
/>,
);
expect(screen.getByTestId('panel-state')).toBeInTheDocument();
expect(screen.getByTestId('icon')).toBeInTheDocument();
expect(screen.getByText('Nothing to visualize yet')).toBeInTheDocument();
expect(screen.getByText('This panel has no query.')).toBeInTheDocument();
});
it('renders no action button when no action is provided', () => {
render(
<PanelMessage icon={null} title="No data" data-testid="panel-state" />,
);
expect(screen.queryByTestId('panel-state-action')).not.toBeInTheDocument();
});
it('renders the action button and fires onClick when pressed', () => {
const onClick = jest.fn();
render(
<PanelMessage
icon={null}
title="Couldnt load panel data"
action={{ label: 'Retry', onClick }}
data-testid="panel-error"
/>,
);
const button = screen.getByTestId('panel-error-action');
expect(button).toHaveTextContent('Retry');
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -32,6 +32,7 @@ function BarPanelRenderer({
panelId,
panel,
data,
refetch,
onClick,
onDragSelect,
dashboardPreference,
@@ -42,9 +43,8 @@ function BarPanelRenderer({
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees the kind, so the cast is a boundary narrowing.
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesBarChartPanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
@@ -138,7 +138,7 @@ function BarPanelRenderer({
data-testid="bar-panel-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData />}
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (

View File

@@ -1,15 +1,18 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
actions: {
view: true,
edit: true,

View File

@@ -3,7 +3,10 @@ import type { SectionConfig } from '../../types/sections';
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true, stacking: true },
},
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'legend', controls: { position: true } },

View File

@@ -26,6 +26,7 @@ function HistogramPanelRenderer({
panelId,
panel,
data,
refetch,
panelMode,
onClick,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
@@ -34,9 +35,8 @@ function HistogramPanelRenderer({
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees the kind, so the cast is a boundary narrowing.
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
@@ -113,7 +113,7 @@ function HistogramPanelRenderer({
data-testid="histogram-panel-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData />}
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (

View File

@@ -1,15 +1,18 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
actions: {
view: true,
edit: true,

View File

@@ -3,6 +3,10 @@ import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: 'visualization',
controls: { switchPanelKind: true },
},
{
kind: 'legend',
controls: { position: true },

View File

@@ -26,14 +26,14 @@ import { useListRowInteraction } from './useListRowInteraction';
import styles from './ListPanel.module.scss';
// `body` flexes to fill the remaining table width (module-level so the resize
// hook's memo dependency stays referentially stable across renders).
// `body` flexes to fill remaining width; module-level to stay referentially stable for the resize hook's memo.
const BODY_FLEX_COLUMNS = ['body'];
function ListPanelRenderer({
panelId,
panel,
data,
refetch,
searchTerm = '',
pagination,
}: PanelRendererProps<'signoz/ListPanel'>): JSX.Element {
@@ -42,16 +42,14 @@ function ListPanelRenderer({
const { height } = useResizeObserver(containerRef);
const { scrollY } = useMemo(() => computeTableLayout(height), [height]);
// The registry guarantees this Renderer only runs for `signoz/ListPanel`, so
// the cast is a documented boundary narrowing.
// `panel` is narrowed to this kind by PanelRendererProps, so no cast needed.
const spec = useMemo<DashboardtypesListPanelSpecDTO>(
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesListPanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
// Telemetry signal of the panel's first builder query drives data flattening,
// per-signal cell rendering, and the row-click behavior (log drawer vs trace
// navigation). Cast at this boundary (the query carries the same string values).
// Telemetry signal of the first builder query; drives flattening, cell rendering,
// and row-click behavior. Cast is safe — the query carries the same string values.
const signal = useMemo(
() =>
(getBuilderQueries(panel.spec.queries)[0]
@@ -83,7 +81,7 @@ function ListPanelRenderer({
[table, signal, formatTimezoneAdjustedTimestamp],
);
// User-resizable columns, persisted per panel; `body` flexes to fill width.
// User-resizable columns, persisted per panel.
const { columns: resizableColumns, components } = useResizableColumns({
panelId,
columns,
@@ -92,8 +90,7 @@ function ListPanelRenderer({
const dataSource = useMemo(() => table?.rows ?? [], [table]);
// Header search filters the current page client-side (V1 parity); paging
// across pages is server-side via `pagination`.
// Header search filters the current page client-side (V1 parity); cross-page paging is server-side via `pagination`.
const filteredDataSource = useMemo(
() => filterTableRows(dataSource, searchTerm),
[dataSource, searchTerm],
@@ -115,8 +112,7 @@ function ListPanelRenderer({
[spec.selectFields],
);
// Show the footer whenever the panel pages server-side (no explicit query
// limit), so the page-size picker is always reachable — V1 parity.
// Show the footer whenever the panel pages server-side, so the page-size picker stays reachable (V1 parity).
const showPager = !!pagination;
return (
@@ -126,7 +122,7 @@ function ListPanelRenderer({
className={PanelStyles.panelContainer}
>
{!table || dataSource.length === 0 ? (
<NoData />
<NoData onRetry={refetch} />
) : (
<>
<div
@@ -142,9 +138,7 @@ function ListPanelRenderer({
components={components}
dataSource={filteredDataSource}
pagination={false}
// Scroll the body vertically only — no `x: 'max-content'`, which
// forced a content-width min and pushed columns off-screen;
// `tableLayout="fixed"` fits them to the available width.
// Vertical scroll only; `x: 'max-content'` forced a content-width min that pushed columns off-screen.
scroll={{ y: scrollY }}
onRow={onRow}
/>

View File

@@ -1,6 +1,5 @@
import {
type DashboardtypesListPanelSpecDTO,
type DashboardtypesPanelDTO,
type QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
@@ -10,16 +9,19 @@ import type {
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { fireEvent, render } from 'tests/test-utils';
import { BaseRendererProps } from '../../../types/rendererProps';
import type {
PanelOfKind,
PanelRendererProps,
} from '../../../types/rendererProps';
import ListPanelRenderer from '../Renderer';
function panelWith(
spec: DashboardtypesListPanelSpecDTO,
): DashboardtypesPanelDTO {
): PanelOfKind<'signoz/ListPanel'> {
return {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/ListPanel', spec } },
} as unknown as DashboardtypesPanelDTO;
} as unknown as PanelOfKind<'signoz/ListPanel'>;
}
// V5 raw response: one result carrying flattened log rows.
@@ -49,9 +51,9 @@ const emptyData: PanelQueryData = {
};
function renderPanel(
props: Partial<BaseRendererProps>,
props: Partial<PanelRendererProps<'signoz/ListPanel'>>,
): ReturnType<typeof render> {
const baseProps: BaseRendererProps = {
const baseProps: PanelRendererProps<'signoz/ListPanel'> = {
panelId: 'panel-1',
panel: panelWith({}),
data: emptyData,

View File

@@ -1,15 +1,17 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
kind: 'signoz/ListPanel',
displayName: 'List',
Renderer,
// Raw records come from logs and traces; metrics don't produce row data.
supportedSignals: [DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
sections,
actions: {
view: true,

View File

@@ -2,4 +2,7 @@ import type { SectionConfig } from '../../types/sections';
// List columns are edited below the query builder, not in the config pane, so
// only Context Links shows here.
export const sections: SectionConfig[] = [{ kind: 'contextLinks' }];
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { switchPanelKind: true } },
{ kind: 'contextLinks' },
];

View File

@@ -16,10 +16,10 @@ import ValueDisplay from './components/ValueDisplay/ValueDisplay';
function NumberPanelRenderer({
panel,
data,
refetch,
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
// The registry guarantees the kind, so the cast is a boundary narrowing.
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesNumberPanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
@@ -60,7 +60,7 @@ function NumberPanelRenderer({
className={PanelStyles.panelContainer}
>
{value === null ? (
<NoData data-testid="number-panel-no-data" />
<NoData data-testid="number-panel-no-data" onRetry={refetch} />
) : (
<ValueDisplay
value={formattedValue}

View File

@@ -1,15 +1,18 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
displayName: 'Number',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
actions: {
view: true,
edit: true,

View File

@@ -1,7 +1,10 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true } },
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'thresholds', controls: { variant: 'comparison' } },
{ kind: 'contextLinks' },

View File

@@ -20,13 +20,13 @@ function PiePanelRenderer({
panelId,
panel,
data,
refetch,
onClick,
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
const isDarkMode = useIsDarkMode();
// The registry guarantees the kind, so the cast is a boundary narrowing.
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesPieChartPanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
@@ -70,7 +70,7 @@ function PiePanelRenderer({
return (
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
{slices.length === 0 ? (
<NoData />
<NoData onRetry={refetch} />
) : (
<Pie
data={slices}

View File

@@ -1,15 +1,18 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
actions: {
view: true,
edit: true,

View File

@@ -3,7 +3,10 @@ import type { SectionConfig } from '../../types/sections';
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
// Legend `colors` is omitted: the pie legend is always interactive swatches.
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true } },
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'legend', controls: { position: true } },
{ kind: 'contextLinks' },

View File

@@ -25,10 +25,10 @@ function TablePanelRenderer({
panelId,
panel,
data,
refetch,
searchTerm = '',
}: PanelRendererProps<'signoz/TablePanel'>): JSX.Element {
// Measure the panel so each page roughly fills it (min 10 rows) and the
// header stays pinned while the body scrolls.
// Measure the panel so each page roughly fills it (min 10 rows) with a pinned header.
const containerRef = useRef<HTMLDivElement>(null);
const { height } = useResizeObserver(containerRef);
const { pageSize, scrollY } = useMemo(
@@ -36,12 +36,9 @@ function TablePanelRenderer({
[height],
);
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/TablePanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
// `panel` is narrowed to this kind by PanelRendererProps, so no cast needed.
const spec = useMemo<DashboardtypesTablePanelSpecDTO>(
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTablePanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
@@ -92,15 +89,13 @@ function TablePanelRenderer({
[table],
);
// Header search filters rows client-side (V1 parity). Falls back to the full
// set when the term is empty, so non-searching tables pay nothing.
// Header search filters rows client-side (V1 parity); empty term returns the full set, so non-searching tables pay nothing.
const filteredDataSource = useMemo(
() => filterTableRows(dataSource, searchTerm),
[dataSource, searchTerm],
);
// Keep pagination in range as the filtered set shrinks: a new term snaps back
// to the first page so the user never lands on a now-empty page.
// Snap back to page 1 on a new search term so the filtered set never lands on a now-empty page.
const [page, setPage] = useState(1);
useEffect(() => setPage(1), [searchTerm]);
@@ -111,7 +106,7 @@ function TablePanelRenderer({
className={PanelStyles.panelContainer}
>
{!table || dataSource.length === 0 ? (
<NoData />
<NoData onRetry={refetch} />
) : (
<div className={styles.container}>
<Table

View File

@@ -1,5 +1,4 @@
import {
type DashboardtypesPanelDTO,
type DashboardtypesTablePanelSpecDTO,
type QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
@@ -7,16 +6,19 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { render } from 'tests/test-utils';
import { BaseRendererProps } from '../../../types/rendererProps';
import type {
PanelOfKind,
PanelRendererProps,
} from '../../../types/rendererProps';
import TablePanelRenderer from '../Renderer';
function panelWith(
spec: DashboardtypesTablePanelSpecDTO,
): DashboardtypesPanelDTO {
): PanelOfKind<'signoz/TablePanel'> {
return {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/TablePanel', spec } },
} as unknown as DashboardtypesPanelDTO;
} as unknown as PanelOfKind<'signoz/TablePanel'>;
}
// V5 scalar response: one joined result with a group column + an aggregation column.
@@ -60,9 +62,9 @@ const emptyData: PanelQueryData = {
};
function renderPanel(
props: Partial<BaseRendererProps>,
props: Partial<PanelRendererProps<'signoz/TablePanel'>>,
): ReturnType<typeof render> {
const baseProps: BaseRendererProps = {
const baseProps: PanelRendererProps<'signoz/TablePanel'> = {
panelId: 'panel-1',
panel: panelWith({}),
data: emptyData,

View File

@@ -1,15 +1,18 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
displayName: 'Table',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
actions: {
view: true,

View File

@@ -4,7 +4,10 @@ import type { SectionConfig } from '../../types/sections';
// single column set). It exposes the per-panel time scope, formatting (decimals +
// per-column units), per-column thresholds, and context links.
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true } },
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: 'formatting', controls: { decimals: true, columnUnits: true } },
{ kind: 'thresholds', controls: { variant: 'table' } },
{ kind: 'contextLinks' },

View File

@@ -32,6 +32,7 @@ function TimeSeriesPanelRenderer({
panelId,
panel,
data,
refetch,
onClick,
onDragSelect,
dashboardPreference,
@@ -42,10 +43,8 @@ function TimeSeriesPanelRenderer({
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees the kind, so the cast is a boundary narrowing.
// Memoized so the `?? {}` fallback doesn't produce a fresh object each render.
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
@@ -140,7 +139,7 @@ function TimeSeriesPanelRenderer({
data-testid="time-series-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData />}
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (

View File

@@ -1,15 +1,18 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
actions: {
view: true,
edit: true,

View File

@@ -1,7 +1,10 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true, fillSpans: true } },
{
kind: 'visualization',
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
},
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'legend', controls: { position: true, colors: true } },

View File

@@ -11,9 +11,7 @@ import type {
} from './types/panelDefinition';
import { PanelKind } from './types/panelKind';
// Pure assembly: each kind owns its own PanelDefinition (see
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a
// single entry below — no other central file needs editing.
// Each kind owns its PanelDefinition; registering a new panel is one entry here.
export const PANELS: PanelRegistry = {
[TimeSeries.kind]: TimeSeries,
[BarChart.kind]: BarChart,
@@ -27,12 +25,7 @@ export const PANELS: PanelRegistry = {
export function getPanelDefinition(
kind: PanelKind,
): RenderablePanelDefinition | undefined {
if (!kind) {
return undefined;
}
// The registry is correlated by kind, so a string lookup yields a union over
// every kind's exactly-typed definition. The renderer cannot be validated
// against that union at the JSX boundary, so widen to the kind-agnostic
// surface here — the single, intentional cast for the whole panel system.
return PANELS[kind] as unknown as RenderablePanelDefinition | undefined;
// Single intentional cast widening the per-kind Renderer to the kind-agnostic
// prop surface (a per-kind renderer can't be statically validated against the union).
return PANELS[kind] as RenderablePanelDefinition | undefined;
}

View File

@@ -1,10 +1,10 @@
import type { ComponentType } from 'react';
import { DataSource } from 'types/common/queryBuilder';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
import type { PanelKind } from './panelKind';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Which panel actions a kind supports. Required field, so registering a new
@@ -35,7 +35,7 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
displayName: string;
Renderer: ComponentType<PanelRendererProps<K>>;
sections: SectionConfig[];
supportedSignals: DataSource[];
supportedSignals: TelemetrytypesSignalDTO[];
actions: PanelActionCapabilities;
}

View File

@@ -1,4 +1,8 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelPluginDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
DashboardCursorSync,
SyncTooltipFilterMode,
@@ -22,37 +26,59 @@ export interface DashboardPreference {
dashboardId?: string;
}
// Kind-agnostic props every renderer receives. Kind-specific interaction props
// are layered on per-kind by PanelRendererProps<K>.
/** Kind-agnostic props every renderer receives; kind-specific interactions are layered on by PanelRendererProps<K>. */
export interface BaseRendererProps {
panelId: string;
/**
* The whole perses panel — renderers derive `spec` and `queries` from this.
* Required: the render boundary only mounts a renderer once the panel and its
* kind are resolved, so a renderer never sees an absent panel.
*/
/** The whole panel — renderers derive `spec` and `queries` from it. Required: the render boundary only mounts once panel + kind resolve. */
panel: DashboardtypesPanelDTO;
/** Raw V5 fetch result — response + the request that produced it. */
data: PanelQueryData;
isLoading: boolean;
error: Error | null;
/** Re-run the panel query; wired to the no-data Retry affordance. Optional so standalone call sites (e.g. the editor preview) can omit it. */
refetch?: () => void;
/** Gate for the drill-down right-click menu. Off by default in V2. */
enableDrillDown?: boolean;
/** Render context (dashboard widget vs. standalone vs. editor); see PanelMode. */
panelMode: PanelMode;
/** Dashboard-level preferences propagated to every panel; shell resolves, renderer consumes. */
dashboardPreference?: DashboardPreference;
/**
* Free-text filter from the header search box, applied client-side. Only
* meaningful for kinds that declare `actions.search`; others ignore it.
*/
/** Free-text header filter, applied client-side. Only meaningful for kinds that declare `actions.search`. */
searchTerm?: string;
/** Server-side paging handles. Present only for raw/list panels; others ignore it. */
pagination?: PanelPagination;
}
// Renderer props for a specific kind: shared base plus that kind's interaction
// surface. Indexing PanelInteractionMap forces it to cover every PanelKind; the
// default K = PanelKind yields the widest surface (a union over all kinds).
export type PanelRendererProps<K extends PanelKind = PanelKind> =
BaseRendererProps & PanelInteractionMap[K];
// The single plugin variant for kind K, picked from the generated plugin union.
// Distributes over the union, coercing each member's nominal kind-enum to its
// string value (`${VK & string}`) to match K. K = PanelKind recovers the full union.
type PluginOfKind<K extends PanelKind> =
DashboardtypesPanelPluginDTO extends infer V
? V extends { kind: infer VK }
? `${VK & string}` extends K
? V
: never
: never
: never;
// The panel narrowed to kind K: the wire DTO with `plugin` (and `plugin.spec`)
// fixed to K's single variant, so a renderer reads `panel.spec.plugin.spec` as
// its own spec type with no cast.
export type PanelOfKind<K extends PanelKind = PanelKind> = Omit<
DashboardtypesPanelDTO,
'spec'
> & {
spec: Omit<DashboardtypesPanelSpecDTO, 'plugin'> & {
plugin: PluginOfKind<K>;
};
};
// Renderer props for kind K: the base (with `panel` narrowed to K) plus K's
// interaction surface (PanelInteractionMap[K]), so a renderer sees its exact spec
// and only the gestures it supports. The default K = PanelKind is the widest surface.
export type PanelRendererProps<K extends PanelKind = PanelKind> = Omit<
BaseRendererProps,
'panel'
> & {
panel: PanelOfKind<K>;
} & PanelInteractionMap[K];

View File

@@ -89,8 +89,11 @@ export interface SectionControls {
spanGaps?: boolean;
};
buckets: { count?: boolean; width?: boolean; mergeQueries?: boolean };
// stackingstackedBarChart (Bar); fillSpans → fill gaps with 0 (TimeSeries).
// switchPanelKindthe visualization-type switcher (every kind, so you can switch
// away from any panel); stacking → stackedBarChart (Bar); fillSpans → fill gaps with
// 0 (TimeSeries).
visualization: {
switchPanelKind: boolean;
timePreference?: boolean;
stacking?: boolean;
fillSpans?: boolean;

View File

@@ -0,0 +1,67 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { sections as barSections } from '../../kinds/BarChartPanel/sections';
import { sections as histogramSections } from '../../kinds/HistogramPanel/sections';
import { sections as listSections } from '../../kinds/ListPanel/sections';
import { sections as timeSeriesSections } from '../../kinds/TimeSeriesPanel/sections';
import type { SectionConfig } from '../../types/sections';
import { buildDefaultPluginSpec } from '../buildDefaultPluginSpec';
describe('buildDefaultPluginSpec', () => {
it('seeds the TimeSeries dropdowns/segmented controls with their renderer defaults', () => {
expect(buildDefaultPluginSpec(timeSeriesSections)).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
chartAppearance: {
lineStyle: DashboardtypesLineStyleDTO.solid,
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
fillMode: DashboardtypesFillModeDTO.none,
},
});
});
it('omits chartAppearance for a kind that does not declare it (Bar)', () => {
expect(buildDefaultPluginSpec(barSections)).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
});
});
it('seeds only the legend for Histogram (no visualization section)', () => {
expect(buildDefaultPluginSpec(histogramSections)).toStrictEqual({
legend: { position: DashboardtypesLegendPositionDTO.bottom },
});
});
it('returns an empty spec for a kind with no seeded controls (List)', () => {
expect(buildDefaultPluginSpec(listSections)).toStrictEqual({});
});
it('does not seed controls that already show a clear default', () => {
// `axes` and `formatting` stay unset — their empty state is the chart default.
const sections: SectionConfig[] = [
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'thresholds', controls: { variant: 'label' } },
{ kind: 'contextLinks' },
];
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
});
it('only seeds the legend position when the kind exposes that control', () => {
const sections: SectionConfig[] = [
{ kind: 'legend', controls: { colors: true } },
];
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
});
});

View File

@@ -0,0 +1,50 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { getPanelQueryType } from '../getPanelQueryType';
function panelWithEnvelopes(envelopes: unknown[]): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'P' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: envelopes.length
? [
{
spec: {
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
},
},
]
: [],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('getPanelQueryType', () => {
it('returns undefined when the panel has no query', () => {
expect(getPanelQueryType(panelWithEnvelopes([]))).toBeUndefined();
});
it('reports the builder mode for builder queries', () => {
const panel = panelWithEnvelopes([
{ type: 'builder_query', spec: { signal: 'traces', name: 'A' } },
]);
expect(getPanelQueryType(panel)).toBe(EQueryType.QUERY_BUILDER);
});
it('reports PromQL when a promql envelope is present', () => {
const panel = panelWithEnvelopes([
{ type: 'promql', spec: { query: 'up', name: 'A' } },
]);
expect(getPanelQueryType(panel)).toBe(EQueryType.PROM);
});
it('reports ClickHouse when a clickhouse_sql envelope is present', () => {
const panel = panelWithEnvelopes([
{ type: 'clickhouse_sql', spec: { query: 'SELECT 1', name: 'A' } },
]);
expect(getPanelQueryType(panel)).toBe(EQueryType.CLICKHOUSE);
});
});

View File

@@ -0,0 +1,69 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SectionConfig, SectionSpecMap } from '../types/sections';
/**
* Seeded plugin-spec slices, typed as canonical section slices so each value is
* checked against its DTO. A partial cross-section, not any single kind's spec,
* so the union cast stays localized to `createDefaultPanel`.
*/
export interface DefaultPluginSpec {
visualization?: SectionSpecMap['visualization'];
legend?: SectionSpecMap['legend'];
chartAppearance?: SectionSpecMap['chartAppearance'];
}
/**
* Seeds per-kind config defaults derived from the kind's declared `sections` so the
* config pane opens populated. Values equal the renderer fallbacks (display only).
* Controls whose empty state already IS the default are left unset.
*/
export function buildDefaultPluginSpec(
sections: SectionConfig[],
): DefaultPluginSpec {
const spec: DefaultPluginSpec = {};
sections.forEach((section) => {
switch (section.kind) {
case 'visualization':
if (section.controls.timePreference) {
spec.visualization = {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
};
}
break;
case 'legend':
if (section.controls.position) {
spec.legend = { position: DashboardtypesLegendPositionDTO.bottom };
}
break;
case 'chartAppearance': {
const chartAppearance: SectionSpecMap['chartAppearance'] = {};
if (section.controls.lineStyle) {
chartAppearance.lineStyle = DashboardtypesLineStyleDTO.solid;
}
if (section.controls.lineInterpolation) {
chartAppearance.lineInterpolation =
DashboardtypesLineInterpolationDTO.spline;
}
if (section.controls.fillMode) {
chartAppearance.fillMode = DashboardtypesFillModeDTO.none;
}
if (Object.keys(chartAppearance).length > 0) {
spec.chartAppearance = chartAppearance;
}
break;
}
default:
break;
}
});
return spec;
}

View File

@@ -0,0 +1,20 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
import { deriveQueryType } from '../../queryV5/persesQueryAdapters';
/**
* The authoring mode (builder / ClickHouse / PromQL) of a panel's query. Returns
* `undefined` when the panel has no query yet so callers can hide query-type chrome
* (e.g. the editor preview's "Plotted with" tag) rather than defaulting to builder.
*/
export function getPanelQueryType(
panel: DashboardtypesPanelDTO,
): EQueryType | undefined {
const envelopes = toQueryEnvelopes(panel.spec.queries ?? []);
if (envelopes.length === 0) {
return undefined;
}
return deriveQueryType(envelopes);
}

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