Compare commits

...

26 Commits

Author SHA1 Message Date
Naman Verma
59c9ecf444 fix: remove unused import 2026-06-25 02:42:15 +05:30
Naman Verma
2c1e1955cc Merge branch 'main' into nv/schema-changes 2026-06-25 02:40:42 +05:30
Abir Roy
a609a4044c fix(ui): resolve monaco find widget clipping and flickering (#11826)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
2026-06-24 20:11:11 +00:00
Nikhil Mantri
f78d98ea71 feat(metrics-explorer): move metric_name from path param to query param (#11745)
* chore: metricName to post body for POST /api/v2/metrics/{metric_name}/metadata

* chore: metricName to query param for GET /api/v2/metrics/{metric_name}/metadata

* chore: added metricName in api get metric attributes

* chore: highlights api modified

* chore: alerts api modified

* chore: dashboards api modified

* chore: description added for metric_name query params

* feat(metrics-explorer): integrate metricName query/body API change in frontend (#11818)

* feat(metrics-explorer): integrate metricName query/body API change in frontend

The metrics-explorer endpoints moved metric_name off the URL path: the
five GETs (attributes, metadata, highlights, alerts, dashboards) now take
a required `metricName` query param, and POST /metadata reads metricName
from the request body.

- Regenerate the orval client from the updated openapi spec, so the GET
  helpers build `/api/v2/metrics/<op>?metricName=...` (URL-encoded, so
  slashed cloud metric names work) and updateMetricMetadata posts to
  `/api/v2/metrics/metadata` with metricName in the body.
- Collapse the useGetMetricAttributes call to the single merged params
  object (metricName + start/end).
- Drop the now-removed pathParams wrapper from both updateMetricMetadata
  call sites; the payload builders already include metricName in the body.
- Update the Metadata test to assert metricName inside the request body.

* revert(metrics-explorer): drop slashed-metric-name band-aid guards

These two defensive guards were added as temporary workarounds for the
metric_name-with-slash bug (SigNoz/signoz#11527, #11528), which returned
200 + HTML instead of JSON. The root cause is fixed by moving metricName
to a query/body param, so the band-aids are no longer needed and revert
to the original intended code.

- MetricDetails.tsx: `!metricMetadataResponse?.data` -> `!metricMetadataResponse`
- AllAttributes.tsx: `?.data?.attributes` -> `?.data.attributes`

* chore: added description for metricName query params

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-24 19:40:09 +00:00
Ashwin Bhatkal
f60e5039be feat(dashboard-v2): toolbar repositioning, JSON editor & expandable variables bar (#11837)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(dashboard-v2): json editor drawer to edit dashboard as raw JSON

Right-side drawer with a Monaco JSON editor (reusing the import-JSON theme),
a Format/Copy/Download/Reset toolbar, live JSON validation, and Apply via the
full-document updateDashboardV2 endpoint. Cmd/Ctrl+Enter applies; Esc closes.

* feat(dashboard-v2): grouped actions menu with clone, new section & edit-as-JSON

Regroup the actions dropdown into labelled Dashboard/Data/Layout groups, add
Clone dashboard (cloneDashboardV2 + navigate) and New section (useAddSection),
and surface an Edit as JSON button that opens the JSON editor drawer. The menu
trigger is a labelled "Actions" button with a 9-grid icon; the time selector
moves out of the actions row to the toolbar's second row.

* feat(dashboard-v2): description-on-hover and space-aware tag overflow

Collapse the dashboard description behind an info icon shown on hover, and move
tags inline after the title where they restrict to the available width and
collapse the remainder into a +N badge (reusing the alerts badge measuring).

* refactor(dashboard-v2): two-row toolbar with a floated, expandable variables bar

Second toolbar row floats the time-range selector top-right; the variables bar
flows beside it, collapsing to a single line with an inline +N trigger that hugs
the last visible pill. Expanding clears the float so the pills pack full-width
on the lines beneath the time selector (no stair-stepping). Overflow pills are
display:none but stay mounted (widths cached) so auto-selection and option
fetching keep driving the panels. Also centre the variable info icon, give the
pills a visible --l3-border (and drop the single-select's stray inner border so
it matches), and replace the toolbar's fuzzy drop shadow with a token hairline.

* feat(dashboard-v2): section title modal & scroll to the new section

New section now opens a title-entry modal instead of inserting a default-named
section, and the view scrolls the freshly created section into view once the
refetch renders it. Generalise the rename modal into a shared SectionTitleModal
reused by both create and rename.

* test(dashboard-v2): cover the JSON editor hook and drawer

useJsonEditor: seeding, live validation, format/reset, dirty tracking, apply
(no-op when clean/invalid, PUTs the narrowed body, error handling) and re-seed
on re-open. JsonEditorDrawer: toolbar/footer wiring, validation text, Apply
enablement, editor changes and Cmd/Ctrl+Enter — with Monaco and the hook mocked.

* refactor(dashboard-v2): extract generic useInlineOverflowCount hook

Address review on the variables bar: generalise the single-line overflow
measurement into hooks/useInlineOverflowCount (container of data-overflow-item
children, with gap/reserveWidth/enabled options) so it's reusable elsewhere, and
clarify the internal variable names (container/itemWidths/availableWidth/etc.).

* refactor(dashboard-v2): use descriptive names in useInlineOverflowCount

Replace single-letter/abbreviated locals (el, i, w, width/widths) with
itemElement/index/itemWidth/cachedWidth(s) inside the measure loop.
2026-06-24 12:54:34 +00:00
Naman Verma
95b7331e42 Merge branch 'main' into nv/schema-changes 2026-06-18 21:09:40 +05:30
Naman Verma
3531ed86f1 fix: reject single-element list default when allowMultiple is false in list variables 2026-06-18 21:08:36 +05:30
Naman Verma
805543cd0d Merge branch 'main' into nv/schema-changes 2026-06-18 20:54:49 +05:30
Naman Verma
9efa0f757d fix: add back enum values 2026-06-18 20:54:16 +05:30
Ashwin Bhatkal
01897f7869 chore: fix variable sort type errors 2026-06-18 19:50:30 +05:30
Naman Verma
d1248ab5a3 chore: remove unsupported enum values (causing errors right now) 2026-06-18 18:00:56 +05:30
Naman Verma
ddf2f3ec53 chore: replicate variable.sort into signoz 2026-06-18 17:39:06 +05:30
Naman Verma
ad4e5b8b89 Merge branch 'main' into nv/schema-changes 2026-06-18 15:40:34 +05:30
Naman Verma
8be973ac14 Merge branch 'main' into nv/schema-changes 2026-06-17 22:00:28 +05:30
Naman Verma
f2422c1fdd fix: replicate text variable spec in signoz to make name required 2026-06-17 22:00:11 +05:30
Naman Verma
6334f26e7d fix: add validations to list variable that text variable has 2026-06-17 20:51:54 +05:30
Naman Verma
563bd2b004 fix: add additional error info on patch application error 2026-06-17 17:42:23 +05:30
Naman Verma
477be8073d fix: reject dashbaords that have vars with the same name 2026-06-17 15:42:22 +05:30
Naman Verma
4e36b9a96a test: add test for missing spec prefix in layout 2026-06-17 14:11:40 +05:30
Naman Verma
7c59e379bd chore: extract out validate panels method 2026-06-17 13:42:29 +05:30
Naman Verma
eec3472e08 fix: check that panels referred in layouts exist 2026-06-17 13:39:33 +05:30
Naman Verma
b0a065591f Merge branch 'main' into nv/schema-changes 2026-06-17 07:29:11 +05:30
Naman Verma
c0e0ebab4a Merge branch 'main' into nv/schema-changes 2026-06-16 20:20:28 +05:30
Naman Verma
9e8464f3eb Merge branch 'main' into nv/schema-changes 2026-06-16 11:40:23 +05:30
Naman Verma
845c88ec45 Merge branch 'main' into nv/schema-changes 2026-06-15 16:55:36 +05:30
Naman Verma
9b53561c31 fix: change schema properties based on UI integration review 2026-06-15 15:37:29 +05:30
47 changed files with 2618 additions and 813 deletions

View File

@@ -2553,17 +2553,6 @@ components:
url:
type: string
type: object
DashboardTextVariableSpec:
properties:
constant:
type: boolean
display:
$ref: '#/components/schemas/VariableDisplay'
name:
type: string
value:
type: string
type: object
DashboardtypesAxes:
properties:
isLogScale:
@@ -2928,9 +2917,15 @@ components:
type: string
nullable: true
type: object
mode:
$ref: '#/components/schemas/DashboardtypesLegendMode'
position:
$ref: '#/components/schemas/DashboardtypesLegendPosition'
type: object
DashboardtypesLegendMode:
enum:
- list
type: string
DashboardtypesLegendPosition:
enum:
- bottom
@@ -2981,15 +2976,25 @@ components:
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
minLength: 1
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesVariablePlugin'
sort:
nullable: true
type: string
$ref: '#/components/schemas/DashboardtypesListVariableSpecSort'
required:
- display
- name
type: object
DashboardtypesListVariableSpecSort:
enum:
- none
- alphabetical-asc
- alphabetical-desc
- numerical-asc
- numerical-desc
- alphabetical-ci-asc
- alphabetical-ci-desc
type: string
DashboardtypesListableDashboardForUserV2:
properties:
dashboards:
@@ -3499,8 +3504,13 @@ components:
DashboardtypesSpanGaps:
properties:
fillLessThan:
description: The maximum gap size to connect when fillOnlyBelow is true.
Gaps larger than this duration are left disconnected.
type: string
fillOnlyBelow:
description: Controls whether lines connect across null values. When false
(default), all gaps are connected. When true, only gaps smaller than fillLessThan
are connected.
type: boolean
type: object
DashboardtypesStorableDashboardData:
@@ -3548,6 +3558,20 @@ components:
- color
- columnName
type: object
DashboardtypesTextVariableSpec:
properties:
constant:
type: boolean
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
minLength: 1
type: string
value:
type: string
required:
- name
type: object
DashboardtypesThresholdFormat:
enum:
- text
@@ -3567,7 +3591,6 @@ components:
required:
- value
- color
- label
type: object
DashboardtypesTimePreference:
enum:
@@ -3652,23 +3675,11 @@ components:
discriminator:
mapping:
ListVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
type: object
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
properties:
kind:
enum:
- TextVariable
type: string
spec:
$ref: '#/components/schemas/DashboardTextVariableSpec'
required:
- kind
- spec
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
type: object
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec:
properties:
@@ -3682,6 +3693,18 @@ components:
- kind
- spec
type: object
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec:
properties:
kind:
enum:
- TextVariable
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTextVariableSpec'
required:
- kind
- spec
type: object
DashboardtypesVariablePlugin:
discriminator:
mapping:
@@ -7767,15 +7790,6 @@ components:
type: object
VariableDefaultValue:
type: object
VariableDisplay:
properties:
description:
type: string
hidden:
type: boolean
name:
type: string
type: object
ZeustypesGettableHost:
properties:
hosts:
@@ -15700,16 +15714,20 @@ paths:
summary: List metric names
tags:
- metrics
/api/v2/metrics/{metric_name}/alerts:
/api/v2/metrics/alerts:
get:
deprecated: false
description: This endpoint returns associated alerts for a specified metric
operationId: GetMetricAlerts
parameters:
- in: path
name: metric_name
- description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
in: query
name: metricName
required: true
schema:
description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
type: string
responses:
"200":
@@ -15764,28 +15782,36 @@ paths:
summary: Get metric alerts
tags:
- metrics
/api/v2/metrics/{metric_name}/attributes:
/api/v2/metrics/attributes:
get:
deprecated: false
description: This endpoint returns attribute keys and their unique values for
a specified metric
operationId: GetMetricAttributes
parameters:
- in: query
name: start
schema:
nullable: true
type: integer
- in: query
name: end
schema:
nullable: true
type: integer
- in: path
name: metric_name
- description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
in: query
name: metricName
required: true
schema:
description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
type: string
- description: Start of the time range as a Unix timestamp in milliseconds.
in: query
name: start
schema:
description: Start of the time range as a Unix timestamp in milliseconds.
nullable: true
type: integer
- description: End of the time range as a Unix timestamp in milliseconds.
in: query
name: end
schema:
description: End of the time range as a Unix timestamp in milliseconds.
nullable: true
type: integer
responses:
"200":
content:
@@ -15839,16 +15865,20 @@ paths:
summary: Get metric attributes
tags:
- metrics
/api/v2/metrics/{metric_name}/dashboards:
/api/v2/metrics/dashboards:
get:
deprecated: false
description: This endpoint returns associated dashboards for a specified metric
operationId: GetMetricDashboards
parameters:
- in: path
name: metric_name
- description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
in: query
name: metricName
required: true
schema:
description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
type: string
responses:
"200":
@@ -15903,17 +15933,21 @@ paths:
summary: Get metric dashboards
tags:
- metrics
/api/v2/metrics/{metric_name}/highlights:
/api/v2/metrics/highlights:
get:
deprecated: false
description: This endpoint returns highlights like number of datapoints, totaltimeseries,
active time series, last received time for a specified metric
operationId: GetMetricHighlights
parameters:
- in: path
name: metric_name
- description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
in: query
name: metricName
required: true
schema:
description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
type: string
responses:
"200":
@@ -15968,17 +16002,79 @@ paths:
summary: Get metric highlights
tags:
- metrics
/api/v2/metrics/{metric_name}/metadata:
/api/v2/metrics/inspect:
post:
deprecated: false
description: Returns raw time series data points for a metric within a time
range (max 30 minutes). Each series includes labels and timestamp/value pairs.
operationId: InspectMetrics
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Inspect raw metric data points
tags:
- metrics
/api/v2/metrics/metadata:
get:
deprecated: false
description: This endpoint returns metadata information like metric description,
unit, type, temporality, monotonicity for a specified metric
operationId: GetMetricMetadata
parameters:
- in: path
name: metric_name
- description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
in: query
name: metricName
required: true
schema:
description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
type: string
responses:
"200":
@@ -16038,12 +16134,6 @@ paths:
description: This endpoint helps to update metadata information like metric
description, unit, type, temporality, monotonicity for a specified metric
operationId: UpdateMetricMetadata
parameters:
- in: path
name: metric_name
required: true
schema:
type: string
requestBody:
content:
application/json:
@@ -16084,64 +16174,6 @@ paths:
summary: Update metric metadata
tags:
- metrics
/api/v2/metrics/inspect:
post:
deprecated: false
description: Returns raw time series data points for a metric within a time
range (max 30 minutes). Each series includes labels and timestamp/value pairs.
operationId: InspectMetrics
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Inspect raw metric data points
tags:
- metrics
/api/v2/metrics/onboarding:
get:
deprecated: false

View File

@@ -19,16 +19,15 @@ import type {
import type {
GetMetricAlerts200,
GetMetricAlertsPathParameters,
GetMetricAlertsParams,
GetMetricAttributes200,
GetMetricAttributesParams,
GetMetricAttributesPathParameters,
GetMetricDashboards200,
GetMetricDashboardsPathParameters,
GetMetricDashboardsParams,
GetMetricHighlights200,
GetMetricHighlightsPathParameters,
GetMetricHighlightsParams,
GetMetricMetadata200,
GetMetricMetadataPathParameters,
GetMetricMetadataParams,
GetMetricsOnboardingStatus200,
GetMetricsStats200,
GetMetricsTreemap200,
@@ -40,7 +39,6 @@ import type {
MetricsexplorertypesTreemapRequestDTO,
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
RenderErrorResponseDTO,
UpdateMetricMetadataPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -146,27 +144,26 @@ export const invalidateListMetrics = async (
* @summary Get metric alerts
*/
export const getMetricAlerts = (
{ metricName }: GetMetricAlertsPathParameters,
params: GetMetricAlertsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAlerts200>({
url: `/api/v2/metrics/${metricName}/alerts`,
url: `/api/v2/metrics/alerts`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricAlertsQueryKey = ({
metricName,
}: GetMetricAlertsPathParameters) => {
return [`/api/v2/metrics/${metricName}/alerts`] as const;
export const getGetMetricAlertsQueryKey = (params?: GetMetricAlertsParams) => {
return [`/api/v2/metrics/alerts`, ...(params ? [params] : [])] as const;
};
export const getGetMetricAlertsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricAlertsPathParameters,
params: GetMetricAlertsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -177,19 +174,13 @@ export const getGetMetricAlertsQueryOptions = <
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricAlertsQueryKey({ metricName });
const queryKey = queryOptions?.queryKey ?? getGetMetricAlertsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMetricAlerts>>> = ({
signal,
}) => getMetricAlerts({ metricName }, signal);
}) => getMetricAlerts(params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
TError,
TData
@@ -209,7 +200,7 @@ export function useGetMetricAlerts<
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricAlertsPathParameters,
params: GetMetricAlertsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -218,7 +209,7 @@ export function useGetMetricAlerts<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricAlertsQueryOptions({ metricName }, options);
const queryOptions = getGetMetricAlertsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -232,11 +223,11 @@ export function useGetMetricAlerts<
*/
export const invalidateGetMetricAlerts = async (
queryClient: QueryClient,
{ metricName }: GetMetricAlertsPathParameters,
params: GetMetricAlertsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricAlertsQueryKey({ metricName }) },
{ queryKey: getGetMetricAlertsQueryKey(params) },
options,
);
@@ -248,12 +239,11 @@ export const invalidateGetMetricAlerts = async (
* @summary Get metric attributes
*/
export const getMetricAttributes = (
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
params: GetMetricAttributesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAttributes200>({
url: `/api/v2/metrics/${metricName}/attributes`,
url: `/api/v2/metrics/attributes`,
method: 'GET',
params,
signal,
@@ -261,21 +251,16 @@ export const getMetricAttributes = (
};
export const getGetMetricAttributesQueryKey = (
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
) => {
return [
`/api/v2/metrics/${metricName}/attributes`,
...(params ? [params] : []),
] as const;
return [`/api/v2/metrics/attributes`, ...(params ? [params] : [])] as const;
};
export const getGetMetricAttributesQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
params: GetMetricAttributesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
@@ -287,19 +272,13 @@ export const getGetMetricAttributesQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetMetricAttributesQueryKey({ metricName }, params);
queryOptions?.queryKey ?? getGetMetricAttributesQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricAttributes>>
> = ({ signal }) => getMetricAttributes({ metricName }, params, signal);
> = ({ signal }) => getMetricAttributes(params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
TData
@@ -319,8 +298,7 @@ export function useGetMetricAttributes<
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
params: GetMetricAttributesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
@@ -329,11 +307,7 @@ export function useGetMetricAttributes<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricAttributesQueryOptions(
{ metricName },
params,
options,
);
const queryOptions = getGetMetricAttributesQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -347,12 +321,11 @@ export function useGetMetricAttributes<
*/
export const invalidateGetMetricAttributes = async (
queryClient: QueryClient,
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
params: GetMetricAttributesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricAttributesQueryKey({ metricName }, params) },
{ queryKey: getGetMetricAttributesQueryKey(params) },
options,
);
@@ -364,27 +337,28 @@ export const invalidateGetMetricAttributes = async (
* @summary Get metric dashboards
*/
export const getMetricDashboards = (
{ metricName }: GetMetricDashboardsPathParameters,
params: GetMetricDashboardsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricDashboards200>({
url: `/api/v2/metrics/${metricName}/dashboards`,
url: `/api/v2/metrics/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricDashboardsQueryKey = ({
metricName,
}: GetMetricDashboardsPathParameters) => {
return [`/api/v2/metrics/${metricName}/dashboards`] as const;
export const getGetMetricDashboardsQueryKey = (
params?: GetMetricDashboardsParams,
) => {
return [`/api/v2/metrics/dashboards`, ...(params ? [params] : [])] as const;
};
export const getGetMetricDashboardsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricDashboardsPathParameters,
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -396,18 +370,13 @@ export const getGetMetricDashboardsQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey({ metricName });
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricDashboards>>
> = ({ signal }) => getMetricDashboards({ metricName }, signal);
> = ({ signal }) => getMetricDashboards(params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
TError,
TData
@@ -427,7 +396,7 @@ export function useGetMetricDashboards<
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricDashboardsPathParameters,
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -436,10 +405,7 @@ export function useGetMetricDashboards<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricDashboardsQueryOptions(
{ metricName },
options,
);
const queryOptions = getGetMetricDashboardsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -453,11 +419,11 @@ export function useGetMetricDashboards<
*/
export const invalidateGetMetricDashboards = async (
queryClient: QueryClient,
{ metricName }: GetMetricDashboardsPathParameters,
params: GetMetricDashboardsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricDashboardsQueryKey({ metricName }) },
{ queryKey: getGetMetricDashboardsQueryKey(params) },
options,
);
@@ -469,27 +435,28 @@ export const invalidateGetMetricDashboards = async (
* @summary Get metric highlights
*/
export const getMetricHighlights = (
{ metricName }: GetMetricHighlightsPathParameters,
params: GetMetricHighlightsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricHighlights200>({
url: `/api/v2/metrics/${metricName}/highlights`,
url: `/api/v2/metrics/highlights`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricHighlightsQueryKey = ({
metricName,
}: GetMetricHighlightsPathParameters) => {
return [`/api/v2/metrics/${metricName}/highlights`] as const;
export const getGetMetricHighlightsQueryKey = (
params?: GetMetricHighlightsParams,
) => {
return [`/api/v2/metrics/highlights`, ...(params ? [params] : [])] as const;
};
export const getGetMetricHighlightsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricHighlightsPathParameters,
params: GetMetricHighlightsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -501,18 +468,13 @@ export const getGetMetricHighlightsQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey({ metricName });
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricHighlights>>
> = ({ signal }) => getMetricHighlights({ metricName }, signal);
> = ({ signal }) => getMetricHighlights(params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
TError,
TData
@@ -532,7 +494,7 @@ export function useGetMetricHighlights<
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricHighlightsPathParameters,
params: GetMetricHighlightsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -541,10 +503,7 @@ export function useGetMetricHighlights<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricHighlightsQueryOptions(
{ metricName },
options,
);
const queryOptions = getGetMetricHighlightsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -558,219 +517,17 @@ export function useGetMetricHighlights<
*/
export const invalidateGetMetricHighlights = async (
queryClient: QueryClient,
{ metricName }: GetMetricHighlightsPathParameters,
params: GetMetricHighlightsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricHighlightsQueryKey({ metricName }) },
{ queryKey: getGetMetricHighlightsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Get metric metadata
*/
export const getMetricMetadata = (
{ metricName }: GetMetricMetadataPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricMetadata200>({
url: `/api/v2/metrics/${metricName}/metadata`,
method: 'GET',
signal,
});
};
export const getGetMetricMetadataQueryKey = ({
metricName,
}: GetMetricMetadataPathParameters) => {
return [`/api/v2/metrics/${metricName}/metadata`] as const;
};
export const getGetMetricMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey({ metricName });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricMetadata>>
> = ({ signal }) => getMetricMetadata({ metricName }, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricMetadata>>
>;
export type GetMetricMetadataQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get metric metadata
*/
export function useGetMetricMetadata<
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricMetadataQueryOptions({ metricName }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get metric metadata
*/
export const invalidateGetMetricMetadata = async (
queryClient: QueryClient,
{ metricName }: GetMetricMetadataPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricMetadataQueryKey({ metricName }) },
options,
);
return queryClient;
};
/**
* This endpoint helps to update metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Update metric metadata
*/
export const updateMetricMetadata = (
{ metricName }: UpdateMetricMetadataPathParameters,
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/metrics/${metricName}/metadata`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: metricsexplorertypesUpdateMetricMetadataRequestDTO,
signal,
});
};
export const getUpdateMetricMetadataMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{
pathParams: UpdateMetricMetadataPathParameters;
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{
pathParams: UpdateMetricMetadataPathParameters;
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
},
TContext
> => {
const mutationKey = ['updateMetricMetadata'];
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 updateMetricMetadata>>,
{
pathParams: UpdateMetricMetadataPathParameters;
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateMetricMetadata(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateMetricMetadataMutationResult = NonNullable<
Awaited<ReturnType<typeof updateMetricMetadata>>
>;
export type UpdateMetricMetadataMutationBody =
| BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>
| undefined;
export type UpdateMetricMetadataMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update metric metadata
*/
export const useUpdateMetricMetadata = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{
pathParams: UpdateMetricMetadataPathParameters;
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{
pathParams: UpdateMetricMetadataPathParameters;
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
},
TContext
> => {
return useMutation(getUpdateMetricMetadataMutationOptions(options));
};
/**
* Returns raw time series data points for a metric within a time range (max 30 minutes). Each series includes labels and timestamp/value pairs.
* @summary Inspect raw metric data points
@@ -854,6 +611,188 @@ export const useInspectMetrics = <
> => {
return useMutation(getInspectMetricsMutationOptions(options));
};
/**
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Get metric metadata
*/
export const getMetricMetadata = (
params: GetMetricMetadataParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricMetadata200>({
url: `/api/v2/metrics/metadata`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricMetadataQueryKey = (
params?: GetMetricMetadataParams,
) => {
return [`/api/v2/metrics/metadata`, ...(params ? [params] : [])] as const;
};
export const getGetMetricMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricMetadata>>
> = ({ signal }) => getMetricMetadata(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricMetadata>>
>;
export type GetMetricMetadataQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get metric metadata
*/
export function useGetMetricMetadata<
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricMetadataQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get metric metadata
*/
export const invalidateGetMetricMetadata = async (
queryClient: QueryClient,
params: GetMetricMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricMetadataQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint helps to update metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Update metric metadata
*/
export const updateMetricMetadata = (
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/metrics/metadata`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: metricsexplorertypesUpdateMetricMetadataRequestDTO,
signal,
});
};
export const getUpdateMetricMetadataMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
TContext
> => {
const mutationKey = ['updateMetricMetadata'];
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 updateMetricMetadata>>,
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> }
> = (props) => {
const { data } = props ?? {};
return updateMetricMetadata(data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateMetricMetadataMutationResult = NonNullable<
Awaited<ReturnType<typeof updateMetricMetadata>>
>;
export type UpdateMetricMetadataMutationBody =
| BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>
| undefined;
export type UpdateMetricMetadataMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update metric metadata
*/
export const useUpdateMetricMetadata = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateMetricMetadata>>,
TError,
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
TContext
> => {
return useMutation(getUpdateMetricMetadataMutationOptions(options));
};
/**
* Lightweight endpoint that checks if any non-SigNoz metrics have been ingested, used for onboarding status detection
* @summary Check if non-SigNoz metrics have been received

View File

@@ -3266,37 +3266,6 @@ export interface DashboardLinkDTO {
url?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type boolean
*/
hidden?: boolean;
/**
* @type string
*/
name?: string;
}
export interface DashboardTextVariableSpecDTO {
/**
* @type boolean
*/
constant?: boolean;
display?: VariableDisplayDTO;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
value?: string;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
@@ -3328,6 +3297,9 @@ export interface DashboardtypesPanelFormattingDTO {
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
@@ -3347,6 +3319,7 @@ export interface DashboardtypesLegendDTO {
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
@@ -3358,7 +3331,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
label: string;
label?: string;
/**
* @type string
*/
@@ -4023,10 +3996,12 @@ export enum DashboardtypesLineStyleDTO {
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
@@ -4658,6 +4633,15 @@ export type DashboardtypesVariablePluginDTO =
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTO
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTO;
export enum DashboardtypesListVariableSpecSortDTO {
none = 'none',
'alphabetical-asc' = 'alphabetical-asc',
'alphabetical-desc' = 'alphabetical-desc',
'numerical-asc' = 'numerical-asc',
'numerical-desc' = 'numerical-desc',
'alphabetical-ci-asc' = 'alphabetical-ci-asc',
'alphabetical-ci-desc' = 'alphabetical-ci-desc',
}
export interface DashboardtypesListVariableSpecDTO {
/**
* @type boolean
@@ -4676,16 +4660,14 @@ export interface DashboardtypesListVariableSpecDTO {
*/
customAllValue?: string;
defaultValue?: VariableDefaultValueDTO;
display: DashboardtypesDisplayDTO;
display?: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name?: string;
name: string;
plugin?: DashboardtypesVariablePluginDTO;
/**
* @type string,null
*/
sort?: string | null;
sort?: DashboardtypesListVariableSpecSortDTO;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO {
@@ -4697,21 +4679,38 @@ export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDash
spec: DashboardtypesListVariableSpecDTO;
}
export enum DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind {
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind {
TextVariable = 'TextVariable',
}
export interface DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO {
export interface DashboardtypesTextVariableSpecDTO {
/**
* @type boolean
*/
constant?: boolean;
display?: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name: string;
/**
* @type string
*/
value?: string;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO {
/**
* @enum TextVariable
* @type string
*/
kind: DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind;
spec: DashboardTextVariableSpecDTO;
kind: DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind;
spec: DashboardtypesTextVariableSpecDTO;
}
export type DashboardtypesVariableDTO =
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO
| DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO;
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO;
export interface DashboardtypesDashboardSpecDTO {
/**
@@ -10370,9 +10369,14 @@ export type ListMetrics200 = {
status: string;
};
export type GetMetricAlertsPathParameters = {
export type GetMetricAlertsParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type GetMetricAlerts200 = {
data: MetricsexplorertypesMetricAlertsResponseDTO;
/**
@@ -10381,18 +10385,20 @@ export type GetMetricAlerts200 = {
status: string;
};
export type GetMetricAttributesPathParameters = {
metricName: string;
};
export type GetMetricAttributesParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
/**
* @type integer,null
* @description undefined
* @description Start of the time range as a Unix timestamp in milliseconds.
*/
start?: number | null;
/**
* @type integer,null
* @description undefined
* @description End of the time range as a Unix timestamp in milliseconds.
*/
end?: number | null;
};
@@ -10405,9 +10411,14 @@ export type GetMetricAttributes200 = {
status: string;
};
export type GetMetricDashboardsPathParameters = {
export type GetMetricDashboardsParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type GetMetricDashboards200 = {
data: MetricsexplorertypesMetricDashboardsResponseDTO;
/**
@@ -10416,9 +10427,14 @@ export type GetMetricDashboards200 = {
status: string;
};
export type GetMetricHighlightsPathParameters = {
export type GetMetricHighlightsParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type GetMetricHighlights200 = {
data: MetricsexplorertypesMetricHighlightsResponseDTO;
/**
@@ -10427,22 +10443,24 @@ export type GetMetricHighlights200 = {
status: string;
};
export type GetMetricMetadataPathParameters = {
metricName: string;
};
export type GetMetricMetadata200 = {
data: MetricsexplorertypesMetricMetadataDTO;
export type InspectMetrics200 = {
data: MetricsexplorertypesInspectMetricsResponseDTO;
/**
* @type string
*/
status: string;
};
export type UpdateMetricMetadataPathParameters = {
export type GetMetricMetadataParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type InspectMetrics200 = {
data: MetricsexplorertypesInspectMetricsResponseDTO;
export type GetMetricMetadata200 = {
data: MetricsexplorertypesMetricMetadataDTO;
/**
* @type string
*/

View File

@@ -191,9 +191,6 @@ function TimeSeries({
if (metrics[0] && yAxisUnit) {
updateMetricMetadata(
{
pathParams: {
metricName: metricNames[0],
},
data: buildUpdateMetricYAxisUnitPayload(
metricNames[0],
metrics[0],

View File

@@ -48,18 +48,14 @@ function AllAttributes({
isLoading: isLoadingAttributes,
isError: isErrorAttributes,
refetch: refetchAttributes,
} = useGetMetricAttributes(
{
metricName,
},
{
start: minTime ? Math.floor(minTime / 1000000) : undefined,
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
},
);
} = useGetMetricAttributes({
metricName,
start: minTime ? Math.floor(minTime / 1000000) : undefined,
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
});
const attributes = useMemo(
() => attributesData?.data?.attributes ?? [],
() => attributesData?.data.attributes ?? [],
[attributesData],
);

View File

@@ -237,9 +237,6 @@ function Metadata({
const handleSave = useCallback(() => {
updateMetricMetadata(
{
pathParams: {
metricName,
},
data: transformUpdateMetricMetadataRequest(metricName, metricMetadataState),
},
{

View File

@@ -56,7 +56,7 @@ function MetricDetails({
);
const metadata = useMemo(() => {
if (!metricMetadataResponse?.data) {
if (!metricMetadataResponse) {
return null;
}
const { type, description, unit, temporality, isMonotonic } =

View File

@@ -195,14 +195,12 @@ describe('Metadata', () => {
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
metricName: MOCK_METRIC_NAME,
type: MetrictypesTypeDTO.sum,
temporality: MetrictypesTemporalityDTO.cumulative,
unit: 'By',
isMonotonic: true,
}),
pathParams: {
metricName: MOCK_METRIC_NAME,
},
}),
expect.objectContaining({
onSuccess: expect.any(Function),

View File

@@ -0,0 +1,117 @@
import { useCallback, useEffect, useRef, useState } from 'react';
interface UseInlineOverflowCountOptions {
itemCount: number;
/** Horizontal gap between items, in px. */
gap?: number;
/** Width kept free at the end of the line for a trailing "+N" trigger, in px. */
reserveWidth?: number;
/** Pause measuring (e.g. while expanded) without unmounting. */
enabled?: boolean;
}
interface UseInlineOverflowCountResult {
containerRef: React.RefObject<HTMLDivElement>;
visibleCount: number;
overflowCount: number;
}
/**
* Measures how many of a container's children (each marked
* `data-overflow-item="true"`) fit on a single line, reserving `reserveWidth`
* for a trailing "+N" trigger. Item widths are cached, so children hidden with
* `display: none` still count toward the fit; measuring pauses while `enabled`
* is false.
*/
export function useInlineOverflowCount({
itemCount,
gap = 8,
reserveWidth = 0,
enabled = true,
}: UseInlineOverflowCountOptions): UseInlineOverflowCountResult {
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(itemCount);
const itemWidthsRef = useRef<number[]>([]);
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
useEffect(() => {
itemWidthsRef.current = [];
setVisibleCount(itemCount);
}, [itemCount]);
const measure = useCallback((): void => {
const container = containerRef.current;
if (!container || !enabledRef.current) {
return;
}
const itemElements = Array.from(container.children).filter(
(itemElement): itemElement is HTMLElement =>
itemElement instanceof HTMLElement &&
itemElement.dataset.overflowItem === 'true',
);
if (itemElements.length === 0) {
setVisibleCount(0);
return;
}
itemElements.forEach((itemElement, index) => {
if (itemElement.offsetWidth > 0) {
itemWidthsRef.current[index] = itemElement.offsetWidth;
}
});
const cachedWidths: number[] = [];
for (let index = 0; index < itemElements.length; index += 1) {
const cachedWidth = itemWidthsRef.current[index];
if (cachedWidth == null) {
// Width not cached yet — reveal everything for one frame so it gets
// measured, then the next pass collapses accurately.
setVisibleCount(itemElements.length);
return;
}
cachedWidths.push(cachedWidth);
}
const containerWidth = container.clientWidth;
const totalWidth = cachedWidths.reduce(
(runningTotal, itemWidth, index) =>
runningTotal + itemWidth + (index > 0 ? gap : 0),
0,
);
if (totalWidth <= containerWidth) {
setVisibleCount(itemElements.length);
return;
}
const availableWidth = containerWidth - reserveWidth;
let usedWidth = 0;
let fitCount = 0;
for (let index = 0; index < cachedWidths.length; index += 1) {
const itemWidthWithGap = cachedWidths[index] + (index > 0 ? gap : 0);
if (usedWidth + itemWidthWithGap > availableWidth && fitCount > 0) {
break;
}
usedWidth += itemWidthWithGap;
fitCount += 1;
}
setVisibleCount(Math.max(1, fitCount));
}, [gap, reserveWidth]);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const observer = new ResizeObserver(() => measure());
observer.observe(container);
Array.from(container.children).forEach((child) => observer.observe(child));
measure();
return (): void => observer.disconnect();
}, [measure, itemCount, enabled]);
return {
containerRef,
visibleCount,
overflowCount: Math.max(0, itemCount - visibleCount),
};
}

View File

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

View File

@@ -1,32 +1,42 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import { generatePath } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import {
Braces,
ClipboardCopy,
Configure,
Ellipsis,
Copy,
FileJson,
Fullscreen,
Grid3X3,
LockKeyhole,
PenLine,
Plus,
SquareStack,
Trash2,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { toast } from '@signozhq/ui/sonner';
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DashboardSettings from '../../DashboardSettings';
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
import SettingsDrawer from '../SettingsDrawer';
import styles from './DashboardActions.module.scss';
import { useDashboardStore } from '../../store/useDashboardStore';
@@ -55,14 +65,31 @@ function DashboardActions({
const canEdit = useDashboardStore((s) => s.isEditable);
const { user } = useAppContext();
const { t } = useTranslation(['dashboard', 'common']);
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState<boolean>(false);
const [isCloning, setIsCloning] = useState<boolean>(false);
const [isNewSectionOpen, setIsNewSectionOpen] = useState<boolean>(false);
const [state, setCopy] = useCopyToClipboard();
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
const { addSection, isSaving: isAddingSection } = useAddSection({
layouts: dashboard.spec.layouts,
});
const handleCreateSection = useCallback(
async (title: string): Promise<void> => {
await addSection(title);
setIsNewSectionOpen(false);
},
[addSection],
);
useEffect(() => {
if (state.error) {
toast.error(t('something_went_wrong', { ns: 'common' }));
@@ -89,6 +116,24 @@ function DashboardActions({
URL.revokeObjectURL(url);
}, [dashboardDataJSON, title]);
const handleClone = useCallback(async (): Promise<void> => {
if (!dashboard.id) {
return;
}
try {
setIsCloning(true);
const response = await cloneDashboardV2({ id: dashboard.id });
toast.success('Dashboard cloned');
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
);
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsCloning(false);
}
}, [dashboard.id, safeNavigate, showErrorModal]);
const handleConfirmDelete = useCallback((): void => {
deleteDashboardMutation.mutate(undefined, {
onSuccess: () => {
@@ -99,17 +144,24 @@ function DashboardActions({
}, [deleteDashboardMutation]);
const menuItems = useMemo<MenuItem[]>(() => {
const editGroup: MenuItem[] = [];
const dashboardGroup: MenuItem[] = [];
if (canEdit) {
editGroup.push({
dashboardGroup.push({
key: 'rename',
label: 'Rename',
icon: <PenLine size={14} />,
onClick: onOpenRename,
});
}
dashboardGroup.push({
key: 'clone',
label: 'Clone dashboard',
icon: <Copy size={14} />,
disabled: isCloning,
onClick: (): void => void handleClone(),
});
if (isAuthor || user.role === USER_ROLES.ADMIN) {
editGroup.push({
dashboardGroup.push({
key: 'lock',
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
icon: <LockKeyhole size={14} />,
@@ -117,14 +169,14 @@ function DashboardActions({
onClick: onLockToggle,
});
}
editGroup.push({
dashboardGroup.push({
key: 'fullscreen',
label: 'Full screen',
icon: <Fullscreen size={14} />,
onClick: handle.enter,
});
const exportGroup: MenuItem[] = [
const dataGroup: MenuItem[] = [
{
key: 'export',
label: 'Export JSON',
@@ -139,7 +191,35 @@ function DashboardActions({
},
];
const dangerGroup: MenuItem[] = [
const layoutGroup: MenuItem[] = [];
if (canEdit) {
layoutGroup.push({
key: 'new-section',
label: 'New section',
icon: <SquareStack size={14} />,
onClick: (): void => setIsNewSectionOpen(true),
});
}
const items: MenuItem[] = [
{
type: 'group',
key: 'group-dashboard',
label: 'Dashboard',
children: dashboardGroup,
},
{ type: 'group', key: 'group-data', label: 'Data', children: dataGroup },
];
if (layoutGroup.length > 0) {
items.push({
type: 'group',
key: 'group-layout',
label: 'Layout',
children: layoutGroup,
});
}
items.push(
{ type: 'divider', key: 'divider-danger' },
{
key: 'delete',
label: 'Delete dashboard',
@@ -147,74 +227,85 @@ function DashboardActions({
danger: true,
onClick: (): void => setIsDeleteOpen(true),
},
];
return [editGroup, exportGroup, dangerGroup]
.filter((group) => group.length > 0)
.flatMap((group, index) =>
index > 0 ? [{ type: 'divider' } as MenuItem, ...group] : group,
);
);
return items;
}, [
isDashboardLocked,
canEdit,
isCloning,
isAuthor,
user.role,
isDashboardLocked,
dashboard.createdBy,
onOpenRename,
handleClone,
onLockToggle,
handle.enter,
exportJSON,
setCopy,
dashboardDataJSON,
canEdit,
]);
return (
<div className={styles.dashboardActionsContainer}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<div className={styles.dashboardActionsSecondary}>
<DropdownMenuSimple menu={{ items: menuItems }}>
<DropdownMenuSimple menu={{ items: menuItems }}>
<Button
variant="solid"
color="secondary"
size="md"
prefix={<Grid3X3 size="md" />}
testId="options"
>
Actions
</Button>
</DropdownMenuSimple>
{canEdit && (
<>
<Button
variant="solid"
color="secondary"
size="icon"
prefix={<Ellipsis size="md" />}
testId="options"
/>
</DropdownMenuSimple>
{canEdit && (
<>
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
{!isDashboardLocked && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
New Panel
Configure
</Button>
)}
</div>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
<Button
variant="solid"
color="secondary"
prefix={<Braces size="md" />}
testId="edit-json"
onClick={(): void => setIsJsonEditorOpen(true)}
size="md"
>
Edit as JSON
</Button>
{!isDashboardLocked && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
size="md"
>
New Panel
</Button>
)}
<JsonEditorDrawer
dashboard={dashboard}
isOpen={isJsonEditorOpen}
onClose={(): void => setIsJsonEditorOpen(false)}
/>
<ConfirmDeleteDialog
open={isDeleteOpen}
title={`Delete dashboard"?`}
@@ -223,6 +314,15 @@ function DashboardActions({
onConfirm={handleConfirmDelete}
onClose={(): void => setIsDeleteOpen(false)}
/>
<SectionTitleModal
open={isNewSectionOpen}
heading="New section"
okText="Create section"
initialValue=""
isSaving={isAddingSection}
onClose={(): void => setIsNewSectionOpen(false)}
onSubmit={handleCreateSection}
/>
</div>
);
}

View File

@@ -1,19 +1,9 @@
.dashboardInfo {
display: flex;
flex-direction: column;
gap: 8px;
width: 40%;
@media (min-width: 1280px) {
width: 30%;
}
}
.dashboardTitleContainer {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.dashboardImage {
@@ -21,9 +11,8 @@
}
.dashboardTitle {
flex: 1;
flex: 0 1 auto;
min-width: 0;
max-width: fit-content;
color: var(--l1-foreground);
font-size: 18px;
font-weight: 500;
@@ -37,6 +26,19 @@
cursor: text !important;
}
.descriptionIcon {
flex-shrink: 0;
color: var(--l2-foreground);
cursor: help;
}
.divider {
flex-shrink: 0;
width: 1px;
height: 18px;
background: var(--l2-border);
}
.dashboardTitleEditor {
display: flex;
align-items: center;
@@ -54,8 +56,13 @@
flex-shrink: 0;
}
/* Flexes into the remaining space and clips so the ResizeObserver can measure
how many tags fit before collapsing the rest into a `+N` badge. */
.dashboardTags {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex: 1 1 0;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
}

View File

@@ -1,5 +1,5 @@
import { KeyboardEvent } from 'react';
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
import { Check, Globe, LockKeyhole, SolidInfoCircle, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
@@ -9,6 +9,7 @@ import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import styles from './DashboardInfo.module.scss';
import { useVisibleTagCount } from './useVisibleTagCount';
import { useDashboardStore } from '../../store/useDashboardStore';
interface DashboardInfoProps {
@@ -45,6 +46,11 @@ function DashboardInfo({
const hasTags = tags.length > 0;
const hasDescription = !isEmpty(description);
const { containerRef, visibleCount } = useVisibleTagCount(tags);
const needsOverflow = tags.length > visibleCount;
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
@@ -56,83 +62,106 @@ function DashboardInfo({
return (
<div className={styles.dashboardInfo}>
<div className={styles.dashboardTitleContainer}>
<img src={image} alt={title} className={styles.dashboardImage} />
{isEditing ? (
<div className={styles.dashboardTitleEditor}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
color="primary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<TooltipSimple title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
<img src={image} alt={title} className={styles.dashboardImage} />
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
</div>
{hasTags && (
<div className={styles.dashboardTags}>
{tags.map((tag) => (
<Badge key={tag} color="warning" variant="outline">
{tag}
</Badge>
))}
{isEditing ? (
<div className={styles.dashboardTitleEditor}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
color="primary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<TooltipSimple title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
{hasDescription && (
<Typography.Text color="muted">{description}</Typography.Text>
<TooltipSimple title={description}>
<SolidInfoCircle
className={styles.descriptionIcon}
size={14}
data-testid="dashboard-description-info"
/>
</TooltipSimple>
)}
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
{hasTags && (
<>
<span className={styles.divider} />
<div
ref={containerRef}
className={styles.dashboardTags}
data-testid="dashboard-tags"
>
{visibleTags.map((tag) => (
<Badge key={tag} color="warning" variant="outline">
{tag}
</Badge>
))}
{remainingTags.length > 0 && (
<TooltipSimple title={remainingTags.join(', ')}>
<Badge
color="warning"
variant="outline"
data-testid="dashboard-tags-overflow"
>
+{remainingTags.length}
</Badge>
</TooltipSimple>
)}
</div>
</>
)}
</div>
);

View File

@@ -0,0 +1,62 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
BADGE_GAP,
estimateBadgeWidth,
OVERFLOW_BADGE_WIDTH,
} from 'components/Alerts/LabelColumn/utils';
interface Result {
containerRef: React.RefObject<HTMLDivElement>;
visibleCount: number;
}
/**
* Measures how many tags fit in the container and returns the visible count,
* reserving room for the `+N` overflow badge. Reuses the badge-width estimation
* from the alerts LabelColumn so dashboards and alerts overflow identically.
*/
export function useVisibleTagCount(tags: string[]): Result {
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(tags.length);
const calculateVisible = useCallback(
(width: number): number => {
if (width <= 0) {
return 1;
}
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
let usedWidth = 0;
let count = 0;
for (const tag of tags) {
const badgeWidth = estimateBadgeWidth(tag) + BADGE_GAP;
if (usedWidth + badgeWidth > availableWidth && count > 0) {
break;
}
usedWidth += badgeWidth;
count += 1;
}
return Math.max(1, count);
},
[tags],
);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0) {
setVisibleCount(calculateVisible(entry.contentRect.width));
}
});
observer.observe(container);
if (container.clientWidth > 0) {
setVisibleCount(calculateVisible(container.clientWidth));
}
return (): void => observer.disconnect();
}, [calculateVisible]);
return { containerRef, visibleCount };
}

View File

@@ -5,7 +5,9 @@
color: var(--l2-foreground);
background-color: var(--l1-background);
padding: 16px;
box-shadow: 0 2px 2px 0px var(--l2-border);
box-shadow:
0 1px 0 0 var(--l2-border),
0 6px 12px -10px var(--l2-border);
}
.dashboardPageToolbarSubContainer {
@@ -16,5 +18,22 @@
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.toolbarRow2 {
width: 100%;
margin-top: 12px;
&::after {
display: block;
clear: both;
content: '';
}
}
.timeCluster {
float: right;
margin: 0 0 0 16px;
}

View File

@@ -0,0 +1,72 @@
.root {
:global(.ant-drawer-wrapper-body) {
border-left: 1px solid var(--l1-border);
background: var(--l2-background);
}
:global(.ant-drawer-header) {
height: 48px;
border-bottom: 1px solid var(--l1-border);
:global(.ant-drawer-title) {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
}
}
:global(.ant-drawer-body) {
padding: 0;
display: flex;
min-height: 0;
}
:global(.ant-drawer-footer) {
padding: 12px 16px;
border-top: 1px solid var(--l1-border);
}
}
.body {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.editor {
flex: 1;
min-height: 0;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.validation {
min-width: 0;
overflow: hidden;
font-family: 'Space Mono', monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.validationValid {
color: var(--bg-forest-400);
}
.validationInvalid {
color: var(--bg-cherry-400);
}
.footerActions {
display: flex;
flex-shrink: 0;
gap: 8px;
}

View File

@@ -0,0 +1,141 @@
import { KeyboardEvent, useCallback } from 'react';
import MEditor from '@monaco-editor/react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { Drawer } from 'antd';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
import styles from './JsonEditorDrawer.module.scss';
import JsonEditorToolbar from './JsonEditorToolbar';
import { useJsonEditor } from './useJsonEditor';
interface JsonEditorDrawerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
isOpen: boolean;
onClose: () => void;
}
function JsonEditorDrawer({
dashboard,
isOpen,
onClose,
}: JsonEditorDrawerProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const onCopy = useCallback((): void => {
copyToClipboard(draft);
toast.success('JSON copied to clipboard');
}, [copyToClipboard, draft]);
const onDownload = useCallback((): void => {
const blob = new Blob([draft], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${dashboard.name || 'dashboard'}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [draft, dashboard.name]);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void apply();
}
},
[apply],
);
const applyDisabled = !isDirty || !validity.valid || isSaving;
const validationText = validity.valid
? `Valid JSON · ${validity.lineCount} lines`
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
return (
<Drawer
title="Dashboard JSON"
placement="right"
width={660}
onClose={onClose}
open={isOpen}
rootClassName={styles.root}
footer={
<div className={styles.footer}>
<Typography.Text
className={cx(styles.validation, {
[styles.validationValid]: validity.valid,
[styles.validationInvalid]: !validity.valid,
})}
data-testid="json-editor-validation"
>
{validationText}
</Typography.Text>
<div className={styles.footerActions}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="json-editor-cancel"
onClick={onClose}
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="md"
testId="json-editor-apply"
disabled={applyDisabled}
onClick={(): void => void apply()}
>
Apply changes
</Button>
</div>
</div>
}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className={styles.body} onKeyDown={onKeyDown}>
<JsonEditorToolbar
isDirty={isDirty}
onFormat={format}
onCopy={onCopy}
onDownload={onDownload}
onReset={reset}
/>
<div className={styles.editor}>
<MEditor
language="json"
height="100%"
value={draft}
onChange={(value): void => setDraft(value ?? '')}
options={{
scrollbar: { alwaysConsumeMouseWheel: false },
minimap: { enabled: false },
fontSize: 13,
fontFamily: 'Space Mono',
}}
theme="vs-dark"
onMount={(editor, monaco): void => {
defineJsonEditorTheme(monaco, editor.getContainerDomNode());
monaco.editor.setTheme(JSON_EDITOR_THEME);
void document.fonts.ready.then(() => monaco.editor.remeasureFonts());
}}
/>
</div>
</div>
</Drawer>
);
}
export default JsonEditorDrawer;

View File

@@ -0,0 +1,12 @@
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);
}
.spacer {
flex: 1;
}

View File

@@ -0,0 +1,69 @@
import { AlignLeft, Copy, Download, RotateCcw } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from './JsonEditorToolbar.module.scss';
interface JsonEditorToolbarProps {
isDirty: boolean;
onFormat: () => void;
onCopy: () => void;
onDownload: () => void;
onReset: () => void;
}
function JsonEditorToolbar({
isDirty,
onFormat,
onCopy,
onDownload,
onReset,
}: JsonEditorToolbarProps): JSX.Element {
return (
<div className={styles.toolbar}>
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<AlignLeft size={14} />}
testId="json-editor-format"
onClick={onFormat}
>
Format
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<Copy size={14} />}
testId="json-editor-copy"
onClick={onCopy}
>
Copy
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<Download size={14} />}
testId="json-editor-download"
onClick={onDownload}
>
Download
</Button>
<div className={styles.spacer} />
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<RotateCcw size={14} />}
testId="json-editor-reset"
disabled={!isDirty}
onClick={onReset}
>
Reset
</Button>
</div>
);
}
export default JsonEditorToolbar;

View File

@@ -0,0 +1,165 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import JsonEditorDrawer from '../JsonEditorDrawer';
import { useJsonEditor } from '../useJsonEditor';
jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
jest.mock('@monaco-editor/react', () => ({
__esModule: true,
default: ({
value,
onChange,
}: {
value: string;
onChange: (next?: string) => void;
}): JSX.Element => (
<textarea
aria-label="json-editor"
data-testid="monaco"
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
jest.mock('react-use', () => ({
useCopyToClipboard: (): [unknown, jest.Mock] => [{}, jest.fn()],
}));
const mockUseJsonEditor = useJsonEditor as jest.Mock;
const dashboard = {
id: 'dash-1',
name: 'My dashboard',
} as unknown as DashboardtypesGettableDashboardV2DTO;
function hookValue(
overrides: Partial<ReturnType<typeof useJsonEditor>> = {},
): ReturnType<typeof useJsonEditor> {
return {
draft: '{\n "a": 1\n}',
setDraft: jest.fn(),
validity: { valid: true, lineCount: 3 },
isDirty: true,
isSaving: false,
format: jest.fn(),
reset: jest.fn(),
apply: jest.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe('JsonEditorDrawer', () => {
beforeEach(() => jest.clearAllMocks());
it('renders the toolbar, editor and footer actions when open', () => {
mockUseJsonEditor.mockReturnValue(hookValue());
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
expect(screen.getByTestId('json-editor-format')).toBeInTheDocument();
expect(screen.getByTestId('json-editor-copy')).toBeInTheDocument();
expect(screen.getByTestId('json-editor-download')).toBeInTheDocument();
expect(screen.getByTestId('json-editor-reset')).toBeInTheDocument();
expect(screen.getByTestId('json-editor-apply')).toBeInTheDocument();
expect(screen.getByTestId('monaco')).toBeInTheDocument();
});
it('shows a valid status with the line count', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({ validity: { valid: true, lineCount: 12 } }),
);
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
expect(screen.getByTestId('json-editor-validation')).toHaveTextContent(
'Valid JSON · 12 lines',
);
});
it('shows the error line and message when invalid', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({
validity: {
valid: false,
lineCount: 4,
errorLine: 3,
message: 'Unexpected token',
},
}),
);
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
expect(screen.getByTestId('json-editor-validation')).toHaveTextContent(
'Line 3 · Unexpected token',
);
});
it('disables Apply when not dirty, invalid, or saving', () => {
mockUseJsonEditor.mockReturnValue(hookValue({ isDirty: false }));
const { rerender } = render(
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
);
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
mockUseJsonEditor.mockReturnValue(
hookValue({ validity: { valid: false, lineCount: 1 } }),
);
rerender(
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
);
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
mockUseJsonEditor.mockReturnValue(hookValue({ isSaving: true }));
rerender(
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
);
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
});
it('wires toolbar and footer buttons to the hook callbacks', () => {
const value = hookValue();
mockUseJsonEditor.mockReturnValue(value);
const onClose = jest.fn();
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={onClose} />);
fireEvent.click(screen.getByTestId('json-editor-format'));
expect(value.format).toHaveBeenCalled();
fireEvent.click(screen.getByTestId('json-editor-reset'));
expect(value.reset).toHaveBeenCalled();
fireEvent.click(screen.getByTestId('json-editor-apply'));
expect(value.apply).toHaveBeenCalled();
fireEvent.click(screen.getByTestId('json-editor-cancel'));
expect(onClose).toHaveBeenCalled();
});
it('forwards editor changes to setDraft', () => {
const value = hookValue();
mockUseJsonEditor.mockReturnValue(value);
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
fireEvent.change(screen.getByTestId('monaco'), {
target: { value: '{"b":2}' },
});
expect(value.setDraft).toHaveBeenCalledWith('{"b":2}');
});
it('applies on Cmd/Ctrl+Enter', () => {
const value = hookValue();
mockUseJsonEditor.mockReturnValue(value);
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
fireEvent.keyDown(screen.getByTestId('monaco'), {
key: 'Enter',
metaKey: true,
});
expect(value.apply).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,179 @@
import { act, renderHook } from '@testing-library/react';
import { updateDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { toast } from '@signozhq/ui/sonner';
import { useJsonEditor } from '../useJsonEditor';
const mockRefetch = jest.fn();
const mockShowErrorModal = jest.fn();
jest.mock('../../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1', refetch: mockRefetch }),
}));
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): { showErrorModal: jest.Mock } => ({
showErrorModal: mockShowErrorModal,
}),
}));
jest.mock('api/generated/services/dashboard', () => ({
updateDashboardV2: jest.fn(),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockUpdate = updateDashboardV2 as jest.Mock;
const mockToastSuccess = toast.success as jest.Mock;
const dashboard = {
id: 'dash-1',
name: 'My dashboard',
schemaVersion: 'v6',
image: 'icon.png',
tags: [{ key: 'env', value: 'prod' }],
spec: {
display: { name: 'My dashboard' },
panels: {},
layouts: [],
variables: [],
},
} as unknown as DashboardtypesGettableDashboardV2DTO;
const serialized = JSON.stringify(dashboard, null, 2);
describe('useJsonEditor', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUpdate.mockResolvedValue({});
});
it('seeds the draft from the dashboard and reports valid, non-dirty state', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
expect(result.current.draft).toBe(serialized);
expect(result.current.isDirty).toBe(false);
expect(result.current.validity.valid).toBe(true);
expect(result.current.validity.lineCount).toBe(serialized.split('\n').length);
});
it('flags invalid JSON with a line number and marks the draft dirty', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() => result.current.setDraft('{\n "name": ,\n}'));
expect(result.current.validity.valid).toBe(false);
expect(result.current.validity.message).toBeDefined();
expect(result.current.isDirty).toBe(true);
});
it('format() pretty-prints valid JSON and leaves invalid JSON untouched', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() => result.current.setDraft('{"a":1}'));
act(() => result.current.format());
expect(result.current.draft).toBe('{\n "a": 1\n}');
act(() => result.current.setDraft('{bad'));
act(() => result.current.format());
expect(result.current.draft).toBe('{bad');
});
it('reset() restores the last-applied text', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() => result.current.setDraft('edited'));
expect(result.current.isDirty).toBe(true);
act(() => result.current.reset());
expect(result.current.draft).toBe(serialized);
expect(result.current.isDirty).toBe(false);
});
it('apply() is a no-op when the draft is unchanged or invalid', async () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
await act(async () => {
await result.current.apply();
});
expect(mockUpdate).not.toHaveBeenCalled();
act(() => result.current.setDraft('{bad'));
await act(async () => {
await result.current.apply();
});
expect(mockUpdate).not.toHaveBeenCalled();
});
it('apply() PUTs the narrowed body, toasts, refetches and calls onApplied', async () => {
const onApplied = jest.fn();
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied }),
);
const next = { ...dashboard, name: 'Renamed' };
act(() => result.current.setDraft(JSON.stringify(next)));
await act(async () => {
await result.current.apply();
});
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate).toHaveBeenCalledWith(
{ id: 'dash-1' },
expect.objectContaining({
name: 'Renamed',
schemaVersion: 'v6',
spec: next.spec,
tags: next.tags,
}),
);
expect(mockToastSuccess).toHaveBeenCalled();
expect(mockRefetch).toHaveBeenCalled();
expect(onApplied).toHaveBeenCalled();
});
it('apply() surfaces errors through the error modal', async () => {
mockUpdate.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() =>
result.current.setDraft(JSON.stringify({ ...dashboard, name: 'X' })),
);
await act(async () => {
await result.current.apply();
});
expect(mockShowErrorModal).toHaveBeenCalled();
});
it('re-seeds the draft when the drawer re-opens', () => {
const onApplied = jest.fn();
const { result, rerender } = renderHook(
(props: { isOpen: boolean }) =>
useJsonEditor({ dashboard, isOpen: props.isOpen, onApplied }),
{ initialProps: { isOpen: false } },
);
act(() => result.current.setDraft('stale edit'));
expect(result.current.draft).toBe('stale edit');
rerender({ isOpen: true });
expect(result.current.draft).toBe(serialized);
});
});

View File

@@ -0,0 +1,26 @@
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesDashboardSpecDTO,
DashboardtypesUpdatableDashboardV2DTO,
TagtypesPostableTagDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Narrow a parsed (full Gettable-shaped) dashboard JSON down to the PUT-updatable
* body. The editor shows the whole dashboard for readability, but the update
* endpoint only accepts `{ name, schemaVersion, image, tags, spec }` — the
* server owns `id`, `locked`, timestamps, etc., so we drop them here.
*/
export function dashboardToUpdatable(
parsed: Record<string, unknown>,
): DashboardtypesUpdatableDashboardV2DTO {
const dashboard = parsed as Partial<DashboardtypesGettableDashboardV2DTO>;
return {
name: dashboard.name ?? '',
schemaVersion: dashboard.schemaVersion ?? 'v6',
image: dashboard.image,
tags: (dashboard.tags as TagtypesPostableTagDTO[] | null | undefined) ?? null,
spec: dashboard.spec as DashboardtypesDashboardSpecDTO,
};
}

View File

@@ -0,0 +1,47 @@
import type { Monaco } from '@monaco-editor/react';
export const JSON_EDITOR_THEME = 'signoz-json';
function token(el: HTMLElement, name: string): string {
return getComputedStyle(el).getPropertyValue(name).trim().replace('#', '');
}
function isDark(hex: string): boolean {
if (hex.length < 6) {
return true;
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return 0.299 * r + 0.587 * g + 0.114 * b < 128;
}
export function defineJsonEditorTheme(monaco: Monaco, el: HTMLElement): void {
const background = token(el, '--l1-background');
const foreground = token(el, '--l1-foreground');
const keyColor = token(el, '--bg-vanilla-400');
const valueColor = token(el, '--bg-robin-400');
const rules: { token: string; foreground: string }[] = [];
if (keyColor) {
rules.push({ token: 'string.key.json', foreground: keyColor });
}
if (valueColor) {
rules.push({ token: 'string.value.json', foreground: valueColor });
}
const colors: Record<string, string> = {};
if (background) {
colors['editor.background'] = `#${background}`;
}
if (foreground) {
colors['editor.foreground'] = `#${foreground}`;
}
monaco.editor.defineTheme(JSON_EDITOR_THEME, {
base: isDark(background) ? 'vs-dark' : 'vs',
inherit: true,
rules,
colors,
});
}

View File

@@ -0,0 +1,148 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { updateDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { dashboardToUpdatable } from './dashboardToUpdatable';
import { useDashboardStore } from '../../store/useDashboardStore';
export interface JsonValidity {
valid: boolean;
lineCount: number;
/** 1-based line of the parse error, when known. */
errorLine?: number;
message?: string;
}
interface Params {
dashboard: DashboardtypesGettableDashboardV2DTO;
isOpen: boolean;
onApplied: () => void;
}
interface Result {
draft: string;
setDraft: (next: string) => void;
validity: JsonValidity;
isDirty: boolean;
isSaving: boolean;
format: () => void;
reset: () => void;
apply: () => Promise<void>;
}
const serialize = (dashboard: DashboardtypesGettableDashboardV2DTO): string =>
JSON.stringify(dashboard, null, 2);
/** Derive a 1-based line number from a `JSON.parse` "position N" error message. */
function errorLineFromMessage(
source: string,
message: string,
): number | undefined {
const match = /position (\d+)/.exec(message);
if (!match) {
return undefined;
}
const position = Number(match[1]);
return source.slice(0, position).split('\n').length;
}
/**
* Editor state for the dashboard JSON drawer: tracks the editable `draft`
* against the last-applied text, exposes live validation, and applies changes
* via the full-document update endpoint.
*/
export function useJsonEditor({
dashboard,
isOpen,
onApplied,
}: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
const [appliedText, setAppliedText] = useState<string>(() =>
serialize(dashboard),
);
const [draft, setDraft] = useState<string>(appliedText);
const [isSaving, setIsSaving] = useState(false);
// Re-seed the editor from the current dashboard each time the drawer opens so
// it always reflects the latest persisted state (e.g. after a refetch).
useEffect(() => {
if (isOpen) {
const next = serialize(dashboard);
setAppliedText(next);
setDraft(next);
}
}, [isOpen, dashboard]);
const validity = useMemo<JsonValidity>(() => {
const lineCount = draft.split('\n').length;
try {
JSON.parse(draft);
return { valid: true, lineCount };
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid JSON';
return {
valid: false,
lineCount,
errorLine: errorLineFromMessage(draft, message),
message,
};
}
}, [draft]);
const isDirty = draft !== appliedText;
const format = useCallback((): void => {
try {
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
} catch {
// Leave the draft untouched when it can't be parsed.
}
}, [draft]);
const reset = useCallback((): void => {
setDraft(appliedText);
}, [appliedText]);
const apply = useCallback(async (): Promise<void> => {
if (!validity.valid || !isDirty) {
return;
}
try {
setIsSaving(true);
const parsed = JSON.parse(draft) as Record<string, unknown>;
await updateDashboardV2({ id: dashboardId }, dashboardToUpdatable(parsed));
toast.success('Dashboard updated');
refetch();
onApplied();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [
dashboardId,
validity.valid,
isDirty,
draft,
refetch,
onApplied,
showErrorModal,
]);
return {
draft,
setDraft,
validity,
isDirty,
isSaving,
format,
reset,
apply,
};
}

View File

@@ -12,6 +12,7 @@ import type {
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useAppContext } from 'providers/App/App';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { useErrorModal } from 'providers/ErrorModalProvider';
@@ -139,7 +140,15 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
/>
</div>
<VariablesBar dashboard={dashboard} />
{/* Row 2: the time selector floats top-right (declared first so the
variables bar's content wraps around it); the variables bar
collapses to one line and, when expanded, wraps full-width under it. */}
<div className={styles.toolbarRow2}>
<div className={styles.timeCluster}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<VariablesBar dashboard={dashboard} />
</div>
</section>
);
}

View File

@@ -1,5 +1,5 @@
import {
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
@@ -9,7 +9,7 @@ import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
DashboardtypesVariablePluginDTO,
DashboardTextVariableSpecDTO,
DashboardtypesTextVariableSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
@@ -19,7 +19,6 @@ import {
signalForApi,
VARIABLE_SORT_DISABLED,
type VariableFormModel,
type VariableSort,
} from './variableFormModel';
/** DTO envelope → flat form model (for display / editing). */
@@ -37,7 +36,7 @@ export function dtoToFormModel(
// Text variable — a distinct envelope (no list plugin).
if (dto.kind === TextEnvelopeKind.TextVariable) {
const spec = dto.spec as DashboardTextVariableSpecDTO;
const spec = dto.spec as DashboardtypesTextVariableSpecDTO;
return {
...common,
type: 'TEXT',
@@ -52,7 +51,7 @@ export function dtoToFormModel(
...common,
multiSelect: spec.allowMultiple ?? false,
showAllOption: spec.allowAllValue ?? false,
sort: (spec.sort as VariableSort) ?? VARIABLE_SORT_DISABLED,
sort: spec.sort ?? VARIABLE_SORT_DISABLED,
defaultValue: spec.defaultValue,
};
const plugin = spec.plugin;

View File

@@ -1,4 +1,7 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesListVariableSpecSortDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
import { sortBy } from 'lodash-es';
@@ -24,19 +27,19 @@ 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.
* Sort order for list-variable values, keyed by the generated wire enum so the
* form model and the DTO `sort` field share one source of truth. The friendly
* keys (`DISABLED` / `ASC` / …) are UI-facing; the values are the Perses `Sort`
* tokens the wire validates against.
*/
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',
DISABLED: DashboardtypesListVariableSpecSortDTO.none,
ASC: DashboardtypesListVariableSpecSortDTO['alphabetical-asc'],
DESC: DashboardtypesListVariableSpecSortDTO['alphabetical-desc'],
NUMERICAL_ASC: DashboardtypesListVariableSpecSortDTO['numerical-asc'],
NUMERICAL_DESC: DashboardtypesListVariableSpecSortDTO['numerical-desc'],
CI_ASC: DashboardtypesListVariableSpecSortDTO['alphabetical-ci-asc'],
CI_DESC: DashboardtypesListVariableSpecSortDTO['alphabetical-ci-desc'],
} as const;
export type VariableSort = (typeof VARIABLE_SORT)[keyof typeof VARIABLE_SORT];

View File

@@ -13,7 +13,7 @@ import { useDashboardStore } from '../../../store/useDashboardStore';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { useRenameSection } from '../hooks/useRenameSection';
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
import RenameSectionModal from '../RenameSectionModal';
import SectionTitleModal from '../SectionTitleModal';
import SectionGrid from '../SectionGrid/SectionGrid';
import SectionHeader, {
type SectionDragHandle,
@@ -146,8 +146,10 @@ function Section({
)}
</div>
))}
<RenameSectionModal
<SectionTitleModal
open={isRenaming}
heading="Rename section"
okText="Rename"
initialValue={section.title}
isSaving={isSaving}
onClose={(): void => setIsRenaming(false)}

View File

@@ -2,21 +2,30 @@ import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
interface RenameSectionModalProps {
interface SectionTitleModalProps {
open: boolean;
/** Modal heading, e.g. "Rename section" / "New section". */
heading: string;
/** Confirm button label, e.g. "Rename" / "Create section". */
okText: string;
initialValue: string;
isSaving: boolean;
placeholder?: string;
onClose: () => void;
onSubmit: (title: string) => void;
}
function RenameSectionModal({
/** Title-entry modal shared by section create and rename. */
function SectionTitleModal({
open,
heading,
okText,
initialValue,
isSaving,
placeholder = 'Section name',
onClose,
onSubmit,
}: RenameSectionModalProps): JSX.Element {
}: SectionTitleModalProps): JSX.Element {
const [value, setValue] = useState<string>(initialValue);
// Reseed the field each time the modal opens.
@@ -36,19 +45,19 @@ function RenameSectionModal({
return (
<Modal
open={open}
title="Rename section"
title={heading}
onCancel={onClose}
onOk={submit}
okText="Rename"
okText={okText}
okButtonProps={{ disabled: isSaving || !value.trim() }}
destroyOnClose
>
<Input
testId="rename-section-input"
testId="section-title-input"
autoFocus
value={value}
maxLength={120}
placeholder="Section name"
placeholder={placeholder}
onChange={(e): void => setValue(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
@@ -61,4 +70,4 @@ function RenameSectionModal({
);
}
export default RenameSectionModal;
export default SectionTitleModal;

View File

@@ -12,6 +12,27 @@ import {
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
const SECTION_SELECTOR = '[data-testid^="dashboard-section-"]';
/**
* Waits (via rAF) for the refetch to render the appended section, then scrolls
* it into view. Polls because `refetch` resolves before React commits the new
* section to the DOM; bails after ~40 frames.
*/
function scrollToNewSection(prevCount: number, attempts = 40): void {
const sections = document.querySelectorAll(SECTION_SELECTOR);
if (sections.length > prevCount) {
sections[sections.length - 1]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
return;
}
if (attempts > 0) {
requestAnimationFrame(() => scrollToNewSection(prevCount, attempts - 1));
}
}
interface Params {
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
@@ -42,10 +63,12 @@ export function useAddSection({ layouts }: Params): Result {
!layouts || layouts.length === 0
? reorderLayoutsOp([newGridLayout(trimmed)])
: addSectionOp(trimmed);
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [op]);
refetch();
scrollToNewSection(prevSectionCount);
} catch (error) {
showErrorModal(error as APIError);
} finally {

View File

@@ -101,7 +101,7 @@ function VariableSelector({
${variable.name}
{variable.description ? (
<Tooltip title={variable.description}>
<SolidInfoCircle className={styles.infoIcon} size="md" />
<SolidInfoCircle className={styles.infoIcon} size={14} />
</Tooltip>
) : null}
</Typography.Text>

View File

@@ -1,12 +1,55 @@
/* Mirrors the V1 dashboard variable bar: each variable is a connected pill —
a robin `$name` segment joined to a value segment. */
/* Sits inside the already-padded sticky toolbar section, so it only needs a top
gap from the tags — horizontal/bottom padding comes from the toolbar. */
.bar {
min-width: 0;
}
.strip {
display: flow-root;
}
.stripExpanded {
display: flex;
flex-wrap: wrap;
gap: 12px;
gap: 8px;
padding-top: 12px;
overflow: visible;
clear: both;
.variableSlot,
.moreButton {
margin: 0;
}
@media (prefers-reduced-motion: no-preference) {
animation: variablesExpandIn 200ms ease-out;
}
}
@keyframes variablesExpandIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.variableSlot {
display: inline-flex;
align-items: center;
margin-right: 8px;
vertical-align: top;
}
.variableSlotHidden {
display: none;
}
.moreButton {
display: inline-flex;
vertical-align: top;
}
.variableItem {
@@ -21,7 +64,7 @@
align-items: center;
gap: 4px;
padding: 6px 6px 6px 8px;
border: 1px solid var(--l1-border);
border: 1px solid var(--l3-border);
border-radius: 2px 0 0 2px;
background: var(--l3-background);
color: var(--bg-robin-300);
@@ -33,8 +76,10 @@
}
.infoIcon {
margin-left: 4px;
display: inline-flex;
margin-left: 2px;
color: var(--l2-foreground);
vertical-align: middle;
}
.variableValue {
@@ -42,7 +87,7 @@
min-width: 120px;
height: 32px;
align-items: center;
border: 1px solid var(--l1-border);
border: 1px solid var(--l3-border);
border-left: none;
border-radius: 0 2px 2px 0;
background: var(--l2-background);
@@ -55,8 +100,6 @@
}
}
/* Inner control fills the value segment; the segment provides the frame, so the
control itself is borderless/transparent. */
.control {
width: 100%;
min-width: 120px;

View File

@@ -1,4 +1,9 @@
import { useState } from 'react';
import { ChevronLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
import { useVariableSelection } from './useVariableSelection';
import VariableSelector from './VariableSelector';
@@ -11,33 +16,76 @@ interface VariablesBarProps {
/**
* Runtime variable selector bar shown above the panels. Renders one control per
* dashboard variable; selections live in the store + URL (never the spec).
*
* The pills sit on the line left of the floated time selector and collapse the
* overflow behind a `+N` trigger. Expanding lets the bar wrap onto full-width
* lines that flow underneath the time selector. Every selector stays mounted
* either way so auto-selection and option fetching keep driving the panels.
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, dependencyData, selection, setSelection } =
useVariableSelection(dashboard);
const [expanded, setExpanded] = useState(false);
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
itemCount: variables.length,
gap: 8,
reserveWidth: 48,
enabled: !expanded,
});
if (variables.length === 0) {
return null;
}
const hasOverflow = overflowCount > 0;
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
{variables.map((variable) => (
<VariableSelector
key={variable.name}
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
/>
))}
<div
ref={containerRef}
className={cx(styles.strip, { [styles.stripExpanded]: expanded })}
>
{variables.map((variable, index) => (
<div
key={variable.name}
data-overflow-item="true"
className={cx(styles.variableSlot, {
[styles.variableSlotHidden]:
!expanded && hasOverflow && index >= visibleCount,
})}
>
<VariableSelector
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
/>
</div>
))}
{hasOverflow && (
<span className={styles.moreButton}>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
aria-expanded={expanded}
testId="dashboard-variables-more"
onClick={(): void => setExpanded((prev) => !prev)}
>
{expanded ? 'Less' : `+${overflowCount}`}
</Button>
</span>
)}
</div>
</div>
);
}

View File

@@ -73,7 +73,7 @@ function ValueSelector({
return (
<CustomSelect
className={styles.select}
className={styles.control}
data-testid={testId}
options={optionData}
value={

View File

@@ -794,6 +794,13 @@ notifications - 2050
background: color-mix(in srgb, var(--l3-background) 20%, transparent);
}
// =================================================================
// Monaco Editor style overrides
.monaco-editor .find-widget.visible {
top: 30px !important;
right: 45px !important;
}
body.ai-assistant-panel-open {
.PylonChat-bubbleFrameContainer,
.PylonChat-chatWindowFrameContainer {

View File

@@ -68,7 +68,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/{metric_name}/attributes", handler.New(
if err := router.Handle("/api/v2/metrics/attributes", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricAttributes),
handler.OpenAPIDef{
ID: "GetMetricAttributes",
@@ -88,7 +88,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/{metric_name}/metadata", handler.New(
if err := router.Handle("/api/v2/metrics/metadata", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricMetadata),
handler.OpenAPIDef{
ID: "GetMetricMetadata",
@@ -96,6 +96,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric metadata",
Description: "This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameQuery),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricMetadata),
ResponseContentType: "application/json",
@@ -107,7 +108,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/{metric_name}/metadata", handler.New(
if err := router.Handle("/api/v2/metrics/metadata", handler.New(
provider.authzMiddleware.EditAccess(provider.metricsExplorerHandler.UpdateMetricMetadata),
handler.OpenAPIDef{
ID: "UpdateMetricMetadata",
@@ -126,7 +127,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/{metric_name}/highlights", handler.New(
if err := router.Handle("/api/v2/metrics/highlights", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricHighlights),
handler.OpenAPIDef{
ID: "GetMetricHighlights",
@@ -134,6 +135,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric highlights",
Description: "This endpoint returns highlights like number of datapoints, totaltimeseries, active time series, last received time for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameQuery),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricHighlightsResponse),
ResponseContentType: "application/json",
@@ -145,7 +147,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/{metric_name}/alerts", handler.New(
if err := router.Handle("/api/v2/metrics/alerts", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricAlerts),
handler.OpenAPIDef{
ID: "GetMetricAlerts",
@@ -153,6 +155,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric alerts",
Description: "This endpoint returns associated alerts for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameQuery),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricAlertsResponse),
ResponseContentType: "application/json",
@@ -164,7 +167,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/{metric_name}/dashboards", handler.New(
if err := router.Handle("/api/v2/metrics/dashboards", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricDashboards),
handler.OpenAPIDef{
ID: "GetMetricDashboards",
@@ -172,6 +175,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric dashboards",
Description: "This endpoint returns associated dashboards for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameQuery),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricDashboardsResponse),
ResponseContentType: "application/json",

View File

@@ -11,17 +11,8 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func extractMetricName(req *http.Request) (string, error) {
metricName := mux.Vars(req)["metric_name"]
if metricName == "" {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "metric_name is required in URL path")
}
return metricName, nil
}
type handler struct {
module metricsexplorer.Module
}
@@ -116,23 +107,17 @@ func (h *handler) UpdateMetricMetadata(rw http.ResponseWriter, req *http.Request
return
}
// Extract metric_name from URL path
vars := mux.Vars(req)
metricName := vars["metric_name"]
if metricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metric_name is required in URL path"))
return
}
var in metricsexplorertypes.UpdateMetricMetadataRequest
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
render.Error(rw, err)
return
}
// Set metric name from URL path
in.MetricName = metricName
if in.MetricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required"))
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
err = h.module.UpdateMetricMetadata(req.Context(), orgID, &in)
@@ -151,11 +136,16 @@ func (h *handler) GetMetricMetadata(rw http.ResponseWriter, req *http.Request) {
return
}
metricName, err := extractMetricName(req)
if err != nil {
var in metricsexplorertypes.MetricNameQuery
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
render.Error(rw, err)
return
}
if err := in.Validate(); err != nil {
render.Error(rw, err)
return
}
metricName := in.MetricName
orgID := valuer.MustNewUUID(claims.OrgID)
@@ -181,20 +171,24 @@ func (h *handler) GetMetricAlerts(rw http.ResponseWriter, req *http.Request) {
return
}
metricName, err := extractMetricName(req)
if err != nil {
var in metricsexplorertypes.MetricNameQuery
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
render.Error(rw, err)
return
}
if err := in.Validate(); err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
if err := h.checkMetricExists(req.Context(), orgID, metricName); err != nil {
if err := h.checkMetricExists(req.Context(), orgID, in.MetricName); err != nil {
render.Error(rw, err)
return
}
out, err := h.module.GetMetricAlerts(req.Context(), orgID, metricName)
out, err := h.module.GetMetricAlerts(req.Context(), orgID, in.MetricName)
if err != nil {
render.Error(rw, err)
return
@@ -209,20 +203,24 @@ func (h *handler) GetMetricDashboards(rw http.ResponseWriter, req *http.Request)
return
}
metricName, err := extractMetricName(req)
if err != nil {
var in metricsexplorertypes.MetricNameQuery
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
render.Error(rw, err)
return
}
if err := in.Validate(); err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
if err := h.checkMetricExists(req.Context(), orgID, metricName); err != nil {
if err := h.checkMetricExists(req.Context(), orgID, in.MetricName); err != nil {
render.Error(rw, err)
return
}
out, err := h.module.GetMetricDashboards(req.Context(), orgID, metricName)
out, err := h.module.GetMetricDashboards(req.Context(), orgID, in.MetricName)
if err != nil {
render.Error(rw, err)
return
@@ -237,20 +235,24 @@ func (h *handler) GetMetricHighlights(rw http.ResponseWriter, req *http.Request)
return
}
metricName, err := extractMetricName(req)
if err != nil {
var in metricsexplorertypes.MetricNameQuery
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
render.Error(rw, err)
return
}
if err := in.Validate(); err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
if err := h.checkMetricExists(req.Context(), orgID, metricName); err != nil {
if err := h.checkMetricExists(req.Context(), orgID, in.MetricName); err != nil {
render.Error(rw, err)
return
}
highlights, err := h.module.GetMetricHighlights(req.Context(), orgID, metricName)
highlights, err := h.module.GetMetricHighlights(req.Context(), orgID, in.MetricName)
if err != nil {
render.Error(rw, err)
return
@@ -265,20 +267,12 @@ func (h *handler) GetMetricAttributes(rw http.ResponseWriter, req *http.Request)
return
}
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
var in metricsexplorertypes.MetricAttributesRequest
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
render.Error(rw, err)
return
}
in.MetricName = metricName
if err := in.Validate(); err != nil {
render.Error(rw, err)
return
@@ -286,7 +280,7 @@ func (h *handler) GetMetricAttributes(rw http.ResponseWriter, req *http.Request)
orgID := valuer.MustNewUUID(claims.OrgID)
if err := h.checkMetricExists(req.Context(), orgID, metricName); err != nil {
if err := h.checkMetricExists(req.Context(), orgID, in.MetricName); err != nil {
render.Error(rw, err)
return
}

View File

@@ -38,7 +38,7 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
FillMode: FillModeSolid,
SpanGaps: SpanGaps{FillLessThan: valuer.MustParseTextDuration("60s")},
},
Legend: Legend{Position: LegendPositionBottom},
Legend: Legend{Position: LegendPositionBottom, Mode: LegendModeList},
},
},
Queries: []Query{

View File

@@ -48,7 +48,42 @@ func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
// ══════════════════════════════════════════════
func (d *DashboardSpec) Validate() error {
if err := d.validateVariables(); err != nil {
return err
}
if err := d.validatePanels(); err != nil {
return err
}
return d.validateLayouts()
}
// validateVariables rejects two variables sharing the same name.
func (d *DashboardSpec) validateVariables() error {
seen := make(map[string]struct{}, len(d.Variables))
for i, v := range d.Variables {
var name string
switch s := v.Spec.(type) {
case *ListVariableSpec:
name = s.Name
case *TextVariableSpec:
name = s.Name
default:
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
return errors.NewInternalf(errors.CodeInternal, "spec.variables[%d].spec: unexpected variable spec type %T", i, v.Spec)
}
if _, dup := seen[name]; dup {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.variables[%d]: duplicate variable name %q", i, name)
}
seen[name] = struct{}{}
}
return nil
}
func (d *DashboardSpec) validatePanels() error {
for key, panel := range d.Panels {
if err := common.ValidateID(key); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "spec.panels: %s", err.Error())
}
if panel == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
}
@@ -69,6 +104,13 @@ func (d *DashboardSpec) Validate() error {
}
func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
compositeSubQueryTypeToPluginKind := map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
}
if !slices.Contains(allowed, plugin.Kind) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: query kind %q is not supported by panel kind %q", path, plugin.Kind, panelKind)
@@ -96,12 +138,35 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
return nil
}
var (
compositeSubQueryTypeToPluginKind = map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
// validateLayouts rejects grid items referencing a panel that doesn't exist.
func (d *DashboardSpec) validateLayouts() error {
for li, layout := range d.Layouts {
grid, ok := layout.Spec.(*dashboard.GridLayoutSpec)
if !ok {
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
}
for ii, item := range grid.Items {
path := fmt.Sprintf("spec.layouts[%d].spec.items[%d].content", li, ii)
if item.Content == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: content reference is required", path)
}
key, err := panelKeyFromRef(item.Content.Path, item.Content.Ref, path)
if err != nil {
return err
}
if _, ok := d.Panels[key]; !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: references unknown panel %q", path, key)
}
}
}
)
return nil
}
// panelKeyFromRef extracts <key> from a "#/spec/panels/<key>" content ref.
func panelKeyFromRef(refPath []string, ref string, path string) (string, error) {
if len(refPath) != 3 || refPath[0] != "spec" || refPath[1] != "panels" {
return "", errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: %q must reference a panel as \"#/spec/panels/<key>\"", path, ref)
}
return refPath[2], nil
}

View File

@@ -73,7 +73,7 @@ func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdatableDashboardV
}
patched, err := p.patch.ApplyWithOptions(raw, &jsonpatch.ApplyOptions{AllowMissingPathOnRemove: true, EnsurePathExistsOnAdd: true})
if err != nil {
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard")
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard").WithAdditional(err.Error())
}
out := &UpdatableDashboardV2{}
if err := json.Unmarshal(patched, out); err != nil {

View File

@@ -405,6 +405,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
out, err := decode(t, `[
{"op": "replace", "path": "/spec/display/name", "value": "Multi-step"},
{"op": "remove", "path": "/spec/panels/p2"},
{"op": "remove", "path": "/spec/layouts/0/spec/items/1"},
{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}
]`).Apply(base)
require.NoError(t, err)

View File

@@ -112,6 +112,174 @@ func TestValidateOnlyVariables(t *testing.T) {
require.NoError(t, err, "expected valid")
}
func TestInvalidateDuplicateVariableNames(t *testing.T) {
data := []byte(`{
"variables": [
{
"kind": "TextVariable",
"spec": {"name": "env", "value": "prod"}
},
{
"kind": "ListVariable",
"spec": {
"name": "env",
"allowAllValue": false,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"}
}
}
}
],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for duplicate variable name")
require.Contains(t, err.Error(), `duplicate variable name "env"`)
}
func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
listVarWithName := func(name string) []byte {
return []byte(`{
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "` + name + `",
"allowAllValue": false,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"}
}
}
}
],
"layouts": []
}`)
}
for _, name := range []string{"my var", "cost$", "bad!", "a/b"} {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName(name))
require.Error(t, err, "expected error for invalid variable name %q", name)
require.Contains(t, err.Error(), "is not a correct name")
})
}
for _, name := range []string{"service", "my_var", "MY_VAR", "MixedCase9", "with-hyphen", "with.dot"} {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName(name))
require.NoError(t, err, "expected valid variable name %q", name)
})
}
t.Run("digits only", func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName("123"))
require.Error(t, err)
require.Contains(t, err.Error(), "cannot contain only digits")
})
}
func TestInvalidatePanelKey(t *testing.T) {
data := []byte(`{
"panels": {
"bad key!": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]
}}}
}]
}
}
},
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for invalid panel key")
require.Contains(t, err.Error(), "is not a correct name")
}
func TestInvalidateListVariableCrossFields(t *testing.T) {
listVar := func(specFields string) []byte {
return []byte(`{
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "service",
` + specFields + `
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"}
}
}
}
],
"layouts": []
}`)
}
t.Run("customAllValue without allowAllValue", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "customAllValue": "*",`))
require.Error(t, err)
require.Contains(t, err.Error(), "customAllValue cannot be set")
})
t.Run("list defaultValue without allowMultiple", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["a", "b"],`))
require.Error(t, err)
require.Contains(t, err.Error(), "allowMultiple")
})
t.Run("single-element list default without allowMultiple", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["only"],`))
require.Error(t, err)
require.Contains(t, err.Error(), "allowMultiple")
})
t.Run("valid sort is accepted", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"sort": "alphabetical-asc",`))
require.NoError(t, err)
})
t.Run("unknown sort is rejected", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"sort": "bogus",`))
require.Error(t, err)
require.Contains(t, err.Error(), "unknown sort")
})
}
func TestInvalidateEmptyVariableName(t *testing.T) {
cases := map[string][]byte{
"text variable": []byte(`{
"variables": [{"kind": "TextVariable", "spec": {"name": "", "value": "x"}}],
"layouts": []
}`),
"list variable": []byte(`{
"variables": [{
"kind": "ListVariable",
"spec": {
"name": "",
"allowAllValue": false,
"allowMultiple": false,
"plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}
}
}],
"layouts": []
}`),
}
for name, data := range cases {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for empty variable name")
require.Contains(t, err.Error(), "name cannot be empty")
})
}
}
func TestInvalidateUnknownPluginKind(t *testing.T) {
tests := []struct {
name string
@@ -270,6 +438,65 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
require.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
}
func TestInvalidateLayoutPanelReferences(t *testing.T) {
validPanels := `"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]
}}}
}]
}
}
}`
layout := func(items string) []byte {
return []byte(`{` + validPanels + `, "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
}
tests := []struct {
name string
data []byte
wantContain string
}{
{
name: "reference to unknown panel",
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/ghost"}}`),
wantContain: `references unknown panel "ghost"`,
},
{
name: "reference not pointing at a panel",
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/variables/p1"}}`),
wantContain: "must reference a panel",
},
{
name: "reference missing spec prefix",
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/panels/p1"}}`),
wantContain: "must reference a panel",
},
{
name: "valid reference",
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}`),
wantContain: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard(tt.data)
if tt.wantContain == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantContain)
})
}
}
func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
tests := []struct {
name string
@@ -569,6 +796,24 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}`,
wantContain: "legend position",
},
{
name: "bad legend mode",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {"legend": {"mode": "grid"}}
}
}
}
},
"layouts": []
}`,
wantContain: "legend mode",
},
{
name: "bad threshold format",
data: `{
@@ -634,6 +879,39 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
// Label on ThresholdWithLabel is optional — the backend never reads it, so a
// threshold with an omitted or empty label must validate cleanly.
func TestThresholdLabelOptional(t *testing.T) {
for _, tt := range []struct {
name string
threshold string
}{
{name: "label omitted", threshold: `{"value": 100, "color": "Red"}`},
{name: "label empty", threshold: `{"value": 100, "color": "Red", "label": ""}`},
} {
t.Run(tt.name, func(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"thresholds": [` + tt.threshold + `]}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"layouts": []
}`)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "threshold without a label should validate")
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
require.Len(t, spec.Thresholds, 1)
require.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
})
}
}
func TestInvalidatePanelWithoutQueries(t *testing.T) {
data := []byte(`{
"panels": {
@@ -749,11 +1027,6 @@ func TestValidateRequiredFields(t *testing.T) {
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "label": "high", "color": ""}]}`),
wantContain: "Color",
},
{
name: "ThresholdWithLabel missing label",
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "color": "Red", "label": ""}]}`),
wantContain: "Label",
},
{
name: "ComparisonThreshold missing value",
data: wrapPanel("signoz/NumberPanel", `{"thresholds": [{"operator": "above", "format": "text", "color": "Red"}]}`),
@@ -811,10 +1084,11 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
require.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
require.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
require.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
require.Equal(t, "solid", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default solid")
require.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
require.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
require.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
require.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
require.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
// Re-marshal the full dashboard (what we'd store in DB / return in API response)
// and verify the output contains the default values.
@@ -825,9 +1099,10 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
"decimalPrecision": `"2"`,
"lineInterpolation": `"spline"`,
"lineStyle": `"solid"`,
"fillMode": `"solid"`,
"fillMode": `"none"`,
"timePreference": `"global_time"`,
"position": `"bottom"`,
"mode": `"list"`,
} {
assert.Contains(t, outputStr, `"`+field+`":`+want, "expected stored/response JSON to contain %s:%s", field, want)
}
@@ -930,7 +1205,7 @@ func TestStorageRoundTrip(t *testing.T) {
assert.Equal(t, "2", tsSpec.Formatting.DecimalPrecision.ValueOrDefault())
assert.Equal(t, "spline", tsSpec.ChartAppearance.LineInterpolation.ValueOrDefault())
assert.Equal(t, "solid", tsSpec.ChartAppearance.LineStyle.ValueOrDefault())
assert.Equal(t, "solid", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
assert.Equal(t, "none", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
assert.Equal(t, "global_time", tsSpec.Visualization.TimePreference.ValueOrDefault())
assert.Equal(t, "bottom", tsSpec.Legend.Position.ValueOrDefault())
numSpec := d.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
@@ -950,7 +1225,7 @@ func TestStorageRoundTrip(t *testing.T) {
assert.Equal(t, "2", tsLoaded.Formatting.DecimalPrecision.ValueOrDefault(), "after load")
assert.Equal(t, "spline", tsLoaded.ChartAppearance.LineInterpolation.ValueOrDefault(), "after load")
assert.Equal(t, "solid", tsLoaded.ChartAppearance.LineStyle.ValueOrDefault(), "after load")
assert.Equal(t, "solid", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
assert.Equal(t, "none", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
assert.Equal(t, "global_time", tsLoaded.Visualization.TimePreference.ValueOrDefault(), "after load")
assert.Equal(t, "bottom", tsLoaded.Legend.Position.ValueOrDefault(), "after load")
numLoaded := loaded.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
@@ -966,7 +1241,7 @@ func TestStorageRoundTrip(t *testing.T) {
"decimalPrecision": `"2"`,
"lineInterpolation": `"spline"`,
"lineStyle": `"solid"`,
"fillMode": `"solid"`,
"fillMode": `"none"`,
"timePreference": `"global_time"`,
"position": `"bottom"`,
"format": `"text"`,

View File

@@ -30,6 +30,7 @@ func TestDashboardSpecMatchesPerses(t *testing.T) {
{"DatasourceSpec", typeOf[DatasourceSpec](), typeOf[datasource.Spec]()},
{"Variable", typeOf[Variable](), typeOf[dashboard.Variable]()},
{"ListVariableSpec", typeOf[ListVariableSpec](), typeOf[dashboard.ListVariableSpec]()},
{"TextVariableSpec", typeOf[TextVariableSpec](), typeOf[dashboard.TextVariableSpec]()},
{"Layout", typeOf[Layout](), typeOf[dashboard.Layout]()},
}

View File

@@ -51,7 +51,7 @@ func (p *PanelPlugin) UnmarshalJSON(data []byte) error {
return err
}
p.Kind = PanelPluginKind(kind)
p.Spec = spec
p.Spec = *spec
return nil
}
@@ -110,7 +110,7 @@ func (p *QueryPlugin) UnmarshalJSON(data []byte) error {
return err
}
p.Kind = QueryPluginKind(kind)
p.Spec = spec
p.Spec = *spec
return nil
}
@@ -165,7 +165,7 @@ func (p *VariablePlugin) UnmarshalJSON(data []byte) error {
return err
}
p.Kind = VariablePluginKind(kind)
p.Spec = spec
p.Spec = *spec
return nil
}
@@ -215,7 +215,7 @@ func (p *DatasourcePlugin) UnmarshalJSON(data []byte) error {
return err
}
p.Kind = DatasourcePluginKind(kind)
p.Spec = spec
p.Spec = *spec
return nil
}
@@ -297,8 +297,7 @@ func extractKindAndSpec(data []byte) (string, []byte, error) {
return head.Kind, head.Spec, nil
}
// decodeSpec strict-decodes a spec JSON into target and runs struct-tag validation (go-playground/validator).
func decodeSpec(specJSON []byte, target any, kind string) (any, error) {
func decodeSpec[T any](specJSON []byte, target T, kind string) (*T, error) {
if len(specJSON) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "kind %q: spec is required", kind)
}
@@ -310,7 +309,12 @@ func decodeSpec(specJSON []byte, target any, kind string) (any, error) {
if err := validator.New().Struct(target); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "kind %q: spec failed validation", kind)
}
return target, nil
if v, ok := any(target).(interface{ validate() error }); ok {
if err := v.validate(); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "kind %q: %s", kind, err.Error())
}
}
return &target, nil
}
// signozDiscriminatorKey is the extension key that signoz.attachDiscriminators

View File

@@ -4,9 +4,11 @@ import (
"encoding/json"
"maps"
"slices"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/common"
"github.com/perses/spec/go/dashboard"
"github.com/perses/spec/go/dashboard/variable"
@@ -84,7 +86,7 @@ type QuerySpec struct {
// ══════════════════════════════════════════════
// Variable is the list/text sum type. Spec is set to *ListVariableSpec or
// *dashboard.TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
// *TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
// discriminated oneOf (see JSONSchemaOneOf).
type Variable struct {
Kind variable.Kind `json:"kind" required:"true"`
@@ -94,7 +96,7 @@ type Variable struct {
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(variable.KindList): schemaRef("DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec"),
string(variable.KindText): schemaRef("DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec"),
string(variable.KindText): schemaRef("DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec"),
})
}
@@ -110,14 +112,14 @@ func (v *Variable) UnmarshalJSON(data []byte) error {
return err
}
v.Kind = variable.KindList
v.Spec = spec
v.Spec = *spec
case string(variable.KindText):
spec, err := decodeSpec(specJSON, new(dashboard.TextVariableSpec), kind)
spec, err := decodeSpec(specJSON, new(TextVariableSpec), kind)
if err != nil {
return err
}
v.Kind = variable.KindText
v.Spec = spec
v.Spec = *spec
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown variable kind %q; allowed values: %s", kind, allowedValuesForKind([]variable.Kind{variable.KindList, variable.KindText}))
}
@@ -127,7 +129,7 @@ func (v *Variable) UnmarshalJSON(data []byte) error {
func (Variable) JSONSchemaOneOf() []any {
return []any{
VariableEnvelope[ListVariableSpec]{Kind: string(variable.KindList)},
VariableEnvelope[dashboard.TextVariableSpec]{Kind: string(variable.KindText)},
VariableEnvelope[TextVariableSpec]{Kind: string(variable.KindText)},
}
}
@@ -143,15 +145,106 @@ func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
// ListVariableSpec mirrors dashboard.ListVariableSpec (variable.ListSpec
// fields + Name) but with a typed VariablePlugin replacing common.Plugin.
type ListVariableSpec struct {
Display Display `json:"display" required:"true"`
Display *Display `json:"display,omitempty"`
DefaultValue *variable.DefaultValue `json:"defaultValue,omitempty"`
AllowAllValue bool `json:"allowAllValue"`
AllowMultiple bool `json:"allowMultiple"`
CustomAllValue string `json:"customAllValue,omitempty"`
CapturingRegexp string `json:"capturingRegexp,omitempty"`
Sort *variable.Sort `json:"sort,omitempty"`
Sort ListVariableSpecSort `json:"sort,omitzero"`
Plugin VariablePlugin `json:"plugin"`
Name string `json:"name"`
Name string `json:"name" required:"true" minLength:"1"`
}
// validate mirrors perses ListVariableSpec validation (plus the digits-only name
// check perses only applies to text variables); run by decodeSpec on unmarshal.
func (s *ListVariableSpec) validate() error {
if err := common.ValidateID(s.Name); err != nil {
return err
}
if _, err := strconv.Atoi(s.Name); err == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
}
if s.CustomAllValue != "" && !s.AllowAllValue {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "customAllValue cannot be set if allowAllValue is not set to true")
}
if s.DefaultValue != nil && len(s.DefaultValue.SliceValues) > 0 && !s.AllowMultiple {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "defaultValue cannot be a list if allowMultiple is not set to true")
}
return nil
}
// ListVariableSpecSort is the value-list sort method, mirrored from Perses as a
// stable enum so the allowed values surface in the generated OpenAPI schema.
type ListVariableSpecSort struct{ valuer.String }
var (
SortNone = ListVariableSpecSort{valuer.NewString("none")}
SortAlphabeticalAsc = ListVariableSpecSort{valuer.NewString("alphabetical-asc")}
SortAlphabeticalDesc = ListVariableSpecSort{valuer.NewString("alphabetical-desc")}
SortNumericalAsc = ListVariableSpecSort{valuer.NewString("numerical-asc")}
SortNumericalDesc = ListVariableSpecSort{valuer.NewString("numerical-desc")}
SortAlphabeticalCaseInsensitiveAsc = ListVariableSpecSort{valuer.NewString("alphabetical-ci-asc")}
SortAlphabeticalCaseInsensitiveDesc = ListVariableSpecSort{valuer.NewString("alphabetical-ci-desc")}
)
func (ListVariableSpecSort) Enum() []any {
return []any{
SortNone,
SortAlphabeticalAsc,
SortAlphabeticalDesc,
SortNumericalAsc,
SortNumericalDesc,
SortAlphabeticalCaseInsensitiveAsc,
SortAlphabeticalCaseInsensitiveDesc,
}
}
func (s ListVariableSpecSort) IsValid() bool {
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
}
// UnmarshalJSON validates against the enum on decode (valuer.String alone
// accepts any string). An empty value is allowed and means "no sort", matching
// Perses.
func (s *ListVariableSpecSort) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid sort: must be a string, one of `none`, `alphabetical-asc`, `alphabetical-desc`, `numerical-asc`, `numerical-desc`, `alphabetical-ci-asc`, or `alphabetical-ci-desc`")
}
if v == "" {
*s = ListVariableSpecSort{}
return nil
}
sort := ListVariableSpecSort{valuer.NewString(v)}
if !sort.IsValid() {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown sort %q: must be `none`, `alphabetical-asc`, `alphabetical-desc`, `numerical-asc`, `numerical-desc`, `alphabetical-ci-asc`, or `alphabetical-ci-desc`", v)
}
*s = sort
return nil
}
// TextVariableSpec replicates dashboard.TextVariableSpec so name can carry the
// required/non-empty schema tags perses leaves off.
type TextVariableSpec struct {
Display *Display `json:"display,omitempty"`
Value string `json:"value"`
Constant bool `json:"constant,omitempty"`
Name string `json:"name" required:"true" minLength:"1"`
}
// validate mirrors perses TextVariableSpec validation; run by decodeSpec on unmarshal.
func (s *TextVariableSpec) validate() error {
if err := common.ValidateID(s.Name); err != nil {
return err
}
if _, err := strconv.Atoi(s.Name); err == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
}
if s.Value == "" && s.Constant {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "value for a constant text variable cannot be empty")
}
return nil
}
// ══════════════════════════════════════════════
@@ -194,7 +287,7 @@ func (l *Layout) UnmarshalJSON(data []byte) error {
return err
}
l.Kind = dashboard.LayoutKind(kind)
l.Spec = spec
l.Spec = *spec
return nil
}

View File

@@ -241,6 +241,7 @@ type TableFormatting struct {
type Legend struct {
Position LegendPosition `json:"position"`
Mode LegendMode `json:"mode"`
CustomColors map[string]string `json:"customColors"`
}
@@ -248,7 +249,7 @@ type ThresholdWithLabel struct {
Value float64 `json:"value" validate:"required" required:"true"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`
Label string `json:"label" validate:"required" required:"true"`
Label string `json:"label"`
}
type ComparisonThreshold struct {
@@ -358,6 +359,47 @@ func (l *LegendPosition) UnmarshalJSON(data []byte) error {
}
}
type LegendMode struct{ valuer.String }
var (
LegendModeList = LegendMode{valuer.NewString("list")} // default
LegendModeTable = LegendMode{valuer.NewString("table")}
)
func (LegendMode) Enum() []any {
return []any{LegendModeList} // others are not supported in UI yet
}
func (m LegendMode) ValueOrDefault() string {
if m.IsZero() {
return LegendModeList.StringValue()
}
return m.StringValue()
}
func (m LegendMode) MarshalJSON() ([]byte, error) {
return json.Marshal(m.ValueOrDefault())
}
func (m *LegendMode) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid legend mode: must be a string, one of `list` or `table`")
}
if v == "" {
*m = LegendModeList
return nil
}
lm := LegendMode{valuer.NewString(v)}
switch lm {
case LegendModeList, LegendModeTable:
*m = lm
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid legend mode %q: must be `list` or `table`", v)
}
}
type ThresholdFormat struct{ valuer.String }
var (
@@ -534,9 +576,9 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
type FillMode struct{ valuer.String }
var (
FillModeSolid = FillMode{valuer.NewString("solid")} // default
FillModeSolid = FillMode{valuer.NewString("solid")}
FillModeGradient = FillMode{valuer.NewString("gradient")}
FillModeNone = FillMode{valuer.NewString("none")}
FillModeNone = FillMode{valuer.NewString("none")} // default
)
func (FillMode) Enum() []any {
@@ -545,7 +587,7 @@ func (FillMode) Enum() []any {
func (fm FillMode) ValueOrDefault() string {
if fm.IsZero() {
return FillModeSolid.StringValue()
return FillModeNone.StringValue()
}
return fm.StringValue()
}
@@ -560,7 +602,7 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
}
if v == "" {
*fm = FillModeSolid
*fm = FillModeNone
return nil
}
val := FillMode{valuer.NewString(v)}
@@ -573,12 +615,9 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
}
}
// SpanGaps controls whether lines connect across null values.
// When FillOnlyBelow is false (default), all gaps are connected.
// When FillOnlyBelow is true, only gaps smaller than FillLessThan are connected.
type SpanGaps struct {
FillOnlyBelow bool `json:"fillOnlyBelow"`
FillLessThan valuer.TextDuration `json:"fillLessThan"`
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
FillLessThan valuer.TextDuration `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
}
type PrecisionOption struct{ valuer.String }

View File

@@ -260,11 +260,27 @@ type MetricHighlightsResponse struct {
ActiveTimeSeries uint64 `json:"activeTimeSeries" required:"true"`
}
// MetricNameQuery represents the query parameters for endpoints that take a metric name.
type MetricNameQuery struct {
MetricName string `query:"metricName" required:"true" description:"The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies)."`
}
// Validate ensures MetricNameQuery contains acceptable values.
func (q *MetricNameQuery) Validate() error {
if q == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
if q.MetricName == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
}
return nil
}
// MetricAttributesRequest represents the query parameters for the metric attributes endpoint.
type MetricAttributesRequest struct {
MetricName string `json:"-"`
Start *int64 `query:"start"`
End *int64 `query:"end"`
MetricName string `query:"metricName" required:"true" description:"The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies)."`
Start *int64 `query:"start" description:"Start of the time range as a Unix timestamp in milliseconds."`
End *int64 `query:"end" description:"End of the time range as a Unix timestamp in milliseconds."`
}
// Validate ensures MetricAttributesRequest contains acceptable values.
@@ -273,6 +289,10 @@ func (req *MetricAttributesRequest) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
if req.MetricName == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
}
if req.Start != nil && req.End != nil {
if *req.Start >= *req.End {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start (%d) must be less than end (%d)", *req.Start, *req.End)