Compare commits

..

8 Commits

Author SHA1 Message Date
swapnil-signoz
53ebd22a64 Merge branch 'issue_5201' of https://github.com/SigNoz/signoz into issue_5201 2026-06-03 19:49:17 +05:30
swapnil-signoz
198734be1f chore: generating frontend types 2026-06-03 19:49:01 +05:30
swapnil-signoz
be27cf48ed Merge branch 'main' into issue_5201 2026-06-03 19:45:53 +05:30
swapnil-signoz
530e01cf5c chore: openapi spec changes 2026-06-03 19:45:21 +05:30
Nikhil Soni
4fce33e2b3 chore: add metric for waterfall monitoring (#11557)
Some checks are pending
build-staging / js-build (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
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
* chore: add metric for waterfall monitoring

Gauge for config limit and counter to count large traces

* chore: use traces instead of tracedetail in metric name

* chore: try removing high cardinality attributes

* chore: use typed argument in logs

* chore: add another metric to get idea of trace sizes

* chore: change config namespace to trace instead of tracedetail

To make it consistant with metric namespaces

* chore: add unit to the windowed response count metric

* chore: use metrics names as per otel conventions

* chore: use constants for metric attributes

* refactor: return error on metric creation failure

* Revert "refactor: return error on metric creation failure"

This reverts commit 091c93e80b.

* chore: panic on metric initiliazation error
2026-06-03 13:30:25 +00:00
Ashwin Bhatkal
a0ae4dfd05 feat(dashboards): V2 dashboard details — base scaffolding (#11543)
* feat(dashboard-v2): route entry — DashboardPageV2 fetches dashboard, gates loading/error

* feat(dashboard-v2): presentational container — compose description & sections layout

* feat(dashboard-v2): breadcrumbs + header chrome

* feat(dashboard-v2): dashboard description — title, meta, actions, rename

* feat(dashboard-v2): read-only panels & sections layout
2026-06-03 12:55:14 +00:00
swapnil-signoz
77b9df5f3a refactor: updating metric names 2026-06-03 12:52:35 +05:30
swapnil-signoz
3d8224e1fe feat: adding assets and dashboards for azure app services 2026-06-03 03:52:09 +05:30
48 changed files with 3425 additions and 1883 deletions

View File

@@ -432,7 +432,7 @@ cloudintegration:
version: v0.0.8
##################### Trace Detail #####################
tracedetail:
traces:
waterfall:
# Number of spans returned per request when the trace is too large to show all at once.
span_page_size: 500

View File

@@ -1261,6 +1261,7 @@ components:
- sqs
- storageaccountsblob
- cdnprofile
- appservice
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -2645,30 +2646,6 @@ components:
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
type: object
DashboardtypesJSONPatchOperation:
properties:
from:
description: Source JSON Pointer for move/copy ops; ignored for other ops.
type: string
op:
$ref: '#/components/schemas/DashboardtypesPatchOp'
path:
description: JSON Pointer (RFC 6901) into the dashboard's postable shape
— e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0,
/tags/-.
type: string
value:
description: 'Value to add/replace/test against. The expected type depends
on the path. Common shapes (see referenced schemas for the exact field
set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N
(or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable;
/spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a
TagtypesPostableTag; /spec/display/name and other leaf string fields take
a string. Required for add/replace/test; ignored for remove/move/copy.'
required:
- op
- path
type: object
DashboardtypesLayout:
oneOf:
- $ref: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec'
@@ -2884,20 +2861,6 @@ components:
$ref: '#/components/schemas/DashboardtypesQuery'
type: array
type: object
DashboardtypesPatchOp:
enum:
- add
- remove
- replace
- move
- copy
- test
type: string
DashboardtypesPatchableDashboardV2:
items:
$ref: '#/components/schemas/DashboardtypesJSONPatchOperation'
nullable: true
type: array
DashboardtypesPieChartPanelSpec:
properties:
formatting:
@@ -3192,27 +3155,6 @@ components:
timeRangeEnabled:
type: boolean
type: object
DashboardtypesUpdateableDashboardV2:
properties:
image:
type: string
name:
type: string
schemaVersion:
type: string
spec:
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
tags:
items:
$ref: '#/components/schemas/TagtypesPostableTag'
nullable: true
type: array
required:
- schemaVersion
- name
- tags
- spec
type: object
DashboardtypesVariable:
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
@@ -12883,12 +12825,6 @@ paths:
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
@@ -12941,12 +12877,6 @@ paths:
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
@@ -12959,12 +12889,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -12979,262 +12903,6 @@ paths:
summary: Get dashboard (v2)
tags:
- dashboard
patch:
deprecated: false
description: 'This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard.
The patch is applied against the postable view of the dashboard (metadata,
data, tags), so individual panels, queries, variables, layouts, or tags can
be updated without re-sending the rest of the dashboard. Apply is lenient:
`remove` on a missing path is a no-op (idempotent) and `add` creates any missing
parent objects, rather than failing as strict RFC 6902 would. The resulting
dashboard is still validated. Locked dashboards are rejected.'
operationId: PatchDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPatchableDashboardV2'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Patch dashboard (v2)
tags:
- dashboard
put:
deprecated: false
description: This endpoint updates a v2-shape dashboard's metadata, data, and
tag set. Locked dashboards are rejected.
operationId: UpdateDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesUpdateableDashboardV2'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Update dashboard (v2)
tags:
- dashboard
/api/v2/dashboards/{id}/lock:
delete:
deprecated: false
description: This endpoint unlocks a v2-shape dashboard. Only the dashboard's
creator or an org admin may lock or unlock.
operationId: UnlockDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Unlock dashboard (v2)
tags:
- dashboard
put:
deprecated: false
description: This endpoint locks a v2-shape dashboard. Only the dashboard's
creator or an org admin may lock or unlock.
operationId: LockDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Lock dashboard (v2)
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false

View File

@@ -221,18 +221,6 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updateable)
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -21,11 +21,9 @@ import type {
CreateDashboardV2201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesPatchableDashboardV2DTO,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatablePublicDashboardDTO,
DashboardtypesUpdateableDashboardV2DTO,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
GetDashboardV2PathParameters,
@@ -35,13 +33,7 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
LockDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
RenderErrorResponseDTO,
UnlockDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdatePublicDashboardPathParameters,
} from '../sigNoz.schemas';
@@ -824,360 +816,3 @@ export const invalidateGetDashboardV2 = async (
return queryClient;
};
/**
* This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.
* @summary Patch dashboard (v2)
*/
export const patchDashboardV2 = (
{ id }: PatchDashboardV2PathParameters,
dashboardtypesPatchableDashboardV2DTONull?: BodyType<DashboardtypesPatchableDashboardV2DTO | null> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PatchDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPatchableDashboardV2DTONull,
signal,
});
};
export const getPatchDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
> => {
const mutationKey = ['patchDashboardV2'];
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 patchDashboardV2>>,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof patchDashboardV2>>
>;
export type PatchDashboardV2MutationBody =
| BodyType<DashboardtypesPatchableDashboardV2DTO | null>
| undefined;
export type PatchDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Patch dashboard (v2)
*/
export const usePatchDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
> => {
return useMutation(getPatchDashboardV2MutationOptions(options));
};
/**
* This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.
* @summary Update dashboard (v2)
*/
export const updateDashboardV2 = (
{ id }: UpdateDashboardV2PathParameters,
dashboardtypesUpdateableDashboardV2DTO?: BodyType<DashboardtypesUpdateableDashboardV2DTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesUpdateableDashboardV2DTO,
signal,
});
};
export const getUpdateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdateableDashboardV2DTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdateableDashboardV2DTO>;
},
TContext
> => {
const mutationKey = ['updateDashboardV2'];
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 updateDashboardV2>>,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdateableDashboardV2DTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof updateDashboardV2>>
>;
export type UpdateDashboardV2MutationBody =
| BodyType<DashboardtypesUpdateableDashboardV2DTO>
| undefined;
export type UpdateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update dashboard (v2)
*/
export const useUpdateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdateableDashboardV2DTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdateableDashboardV2DTO>;
},
TContext
> => {
return useMutation(getUpdateDashboardV2MutationOptions(options));
};
/**
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Unlock dashboard (v2)
*/
export const unlockDashboardV2 = (
{ id }: UnlockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'DELETE',
signal,
});
};
export const getUnlockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unlockDashboardV2'];
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 unlockDashboardV2>>,
{ pathParams: UnlockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unlockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnlockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unlockDashboardV2>>
>;
export type UnlockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unlock dashboard (v2)
*/
export const useUnlockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
return useMutation(getUnlockDashboardV2MutationOptions(options));
};
/**
* This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Lock dashboard (v2)
*/
export const lockDashboardV2 = (
{ id }: LockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'PUT',
signal,
});
};
export const getLockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['lockDashboardV2'];
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 lockDashboardV2>>,
{ pathParams: LockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return lockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type LockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof lockDashboardV2>>
>;
export type LockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Lock dashboard (v2)
*/
export const useLockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};

View File

@@ -2574,6 +2574,7 @@ export enum CloudintegrationtypesServiceIDDTO {
sqs = 'sqs',
storageaccountsblob = 'storageaccountsblob',
cdnprofile = 'cdnprofile',
appservice = 'appservice',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -4653,32 +4654,6 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
}
export enum DashboardtypesPatchOpDTO {
add = 'add',
remove = 'remove',
replace = 'replace',
move = 'move',
copy = 'copy',
test = 'test',
}
export interface DashboardtypesJSONPatchOperationDTO {
/**
* @type string
* @description Source JSON Pointer for move/copy ops; ignored for other ops.
*/
from?: string;
op: DashboardtypesPatchOpDTO;
/**
* @type string
* @description JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-.
*/
path: string;
/**
* @description Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy.
*/
value?: unknown;
}
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
@@ -4688,13 +4663,6 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
}
/**
* @nullable
*/
export type DashboardtypesPatchableDashboardV2DTO =
| DashboardtypesJSONPatchOperationDTO[]
| null;
export interface DashboardtypesPostableDashboardV2DTO {
/**
* @type boolean
@@ -4749,26 +4717,6 @@ export interface DashboardtypesUpdatablePublicDashboardDTO {
timeRangeEnabled?: boolean;
}
export interface DashboardtypesUpdateableDashboardV2DTO {
/**
* @type string
*/
image?: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
schemaVersion: string;
spec: DashboardtypesDashboardSpecDTO;
/**
* @type array,null
*/
tags: TagtypesPostableTagDTO[] | null;
}
export enum DashboardtypesVariablePluginKindDTO {
'signoz/DynamicVariable' = 'signoz/DynamicVariable',
'signoz/QueryVariable' = 'signoz/QueryVariable',
@@ -9529,34 +9477,6 @@ export type GetDashboardV2200 = {
status: string;
};
export type PatchDashboardV2PathParameters = {
id: string;
};
export type PatchDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type UpdateDashboardV2PathParameters = {
id: string;
};
export type UpdateDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type UnlockDashboardV2PathParameters = {
id: string;
};
export type LockDashboardV2PathParameters = {
id: string;
};
export type GetFeatures200 = {
/**
* @type array

View File

@@ -0,0 +1,5 @@
.container {
display: flex;
flex-direction: column;
height: 100%;
}

View File

@@ -0,0 +1,198 @@
import { useEffect, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import {
ClipboardCopy,
Ellipsis,
FileJson,
Fullscreen,
LockKeyhole,
PenLine,
Plus,
} from '@signozhq/icons';
import { Popover } from 'antd';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import styles from '../DashboardDescription.module.scss';
interface Props {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
isDashboardLocked: boolean;
editDashboard: boolean;
isAuthor: boolean;
addPanelPermission: boolean;
onAddPanel: () => void;
onLockToggle: () => void;
onOpenRename: () => void;
}
function DashboardActions({
dashboard,
handle,
isDashboardLocked,
editDashboard,
isAuthor,
addPanelPermission,
onAddPanel,
onLockToggle,
onOpenRename,
}: Props): JSX.Element {
const { user } = useAppContext();
const { t } = useTranslation(['dashboard', 'common']);
const id = dashboard.id;
const title = dashboard.spec?.display?.name ?? '';
const [isDashboardSettingsOpen, setIsDashboardSettingsOpen] =
useState<boolean>(false);
const [state, setCopy] = useCopyToClipboard();
useEffect(() => {
if (state.error) {
toast.error(t('something_went_wrong', { ns: 'common' }));
}
if (state.value) {
toast.success(t('success', { ns: 'common' }));
}
}, [state.error, state.value, t]);
const dashboardDataJSON = (): string => JSON.stringify(dashboard, null, 2);
const exportJSON = (): void => {
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${title || 'dashboard'}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<div className={styles.rightSection}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<Popover
open={isDashboardSettingsOpen}
arrow={false}
onOpenChange={(visible): void => setIsDashboardSettingsOpen(visible)}
rootClassName={styles.dashboardSettings}
content={
<div className={styles.menuContent}>
<section className={styles.section1}>
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<TooltipSimple
title={
dashboard.createdBy === 'integration'
? 'Dashboards created by integrations cannot be unlocked'
: ''
}
>
<Button
variant="ghost"
prefix={<LockKeyhole size={14} />}
disabled={dashboard.createdBy === 'integration'}
onClick={(): void => {
setIsDashboardSettingsOpen(false);
onLockToggle();
}}
testId="lock-unlock-dashboard"
>
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
</TooltipSimple>
)}
{!isDashboardLocked && editDashboard && (
<Button
variant="ghost"
prefix={<PenLine size={14} />}
onClick={(): void => {
onOpenRename();
setIsDashboardSettingsOpen(false);
}}
>
Rename
</Button>
)}
<Button
variant="ghost"
prefix={<Fullscreen size={14} />}
onClick={handle.enter}
>
Full screen
</Button>
</section>
<section className={styles.section2}>
<Button
variant="ghost"
prefix={<FileJson size={14} />}
onClick={(): void => {
exportJSON();
setIsDashboardSettingsOpen(false);
}}
>
Export JSON
</Button>
<Button
variant="ghost"
prefix={<ClipboardCopy size={14} />}
onClick={(): void => {
setCopy(dashboardDataJSON());
setIsDashboardSettingsOpen(false);
}}
>
Copy as JSON
</Button>
</section>
<section className={styles.deleteDashboard}>
<DeleteButton
createdBy={dashboard.createdBy || ''}
name={title}
id={id}
isLocked={isDashboardLocked}
routeToListPage
/>
</section>
</div>
}
trigger="click"
placement="bottomRight"
>
<Button
variant="ghost"
size="icon"
prefix={<Ellipsis size={14} />}
className={styles.icons}
testId="options"
/>
</Popover>
{!isDashboardLocked && addPanelPermission && (
<Button
variant="solid"
color="primary"
className={styles.addPanelBtn}
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
>
New Panel
</Button>
)}
</div>
);
}
export default DashboardActions;

View File

@@ -0,0 +1,303 @@
.dashboardDescriptionContainer {
box-shadow: none;
border: none;
background: unset;
color: var(--l2-foreground);
:global(.ant-card-body) {
padding: 0px;
}
.dashboardDetails {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 16px 16px 0px 16px;
align-items: flex-start;
.leftSection {
display: flex;
align-items: center;
gap: 8px;
width: 45%;
.dashboardImg {
height: 16px;
width: 16px;
}
.dashboardTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 150% */
letter-spacing: -0.08px;
max-width: 80%;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.publicDashboardIcon {
margin-right: 4px;
}
}
.rightSection {
display: flex;
width: 55%;
justify-content: flex-end;
flex-wrap: wrap;
align-items: center;
gap: 14px;
.icons {
display: flex;
align-items: center;
width: 32px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.icons:hover {
background-color: unset;
}
.configureButton {
display: flex;
align-items: center;
width: 93px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.addPanelBtn {
display: flex;
width: 119px;
height: 34px;
padding: 5.937px 11.875px;
justify-content: center;
align-items: center;
color: var(--primary-foreground);
background: var(--primary-background);
font-family: Inter;
font-size: 11.875px;
font-style: normal;
font-weight: 500;
line-height: 17.812px; /* 150% */
}
}
}
.dashboardTags {
display: flex;
gap: 6px;
padding: 16px 16px 0px 16px;
flex-wrap: wrap;
.tag {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
text-align: center;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
margin-inline-end: 0px;
}
}
.dashboardDescriptionSection {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
padding: 20px 16px 0px 16px;
}
}
.dashboardSettings {
width: 191px;
height: 302px;
flex-shrink: 0;
:global(.ant-popover-inner) {
padding: 0px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
) !important;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.menuContent {
display: flex;
flex-direction: column;
section {
display: flex;
flex-direction: column;
align-items: start;
button {
display: flex;
width: 100%;
height: unset;
padding: 8px;
align-items: center;
gap: 12px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
border-top: none;
}
}
.section1,
.section2 {
border-bottom: 1px solid var(--l1-border);
}
.deleteDashboard button {
color: var(--bg-cherry-400) !important;
}
}
}
.renameDashboard {
:global(.ant-modal-content) {
width: 384px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0px;
:global(.ant-modal-header) {
height: 52px;
padding: 16px;
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
margin-bottom: 0px;
:global(.ant-modal-title) {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
width: 349px;
height: 20px;
}
}
:global(.ant-modal-body) {
padding: 16px;
.dashboardContent {
display: flex;
flex-direction: column;
gap: 8px;
.nameText {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.dashboardNameInput {
display: flex;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
align-self: stretch;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
}
:global(.ant-modal-footer) {
padding: 16px;
margin-top: 0px;
.dashboardRename {
display: flex;
flex-direction: row-reverse;
gap: 12px;
.cancelBtn {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--l1-border);
}
.renameBtn {
display: flex;
align-items: center;
width: 169px;
padding: 4px 8px;
justify-content: center;
gap: 4px;
border-radius: 2px;
background: var(--primary-background);
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,47 @@
import { Globe, LockKeyhole } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import styles from '../DashboardDescription.module.scss';
interface Props {
title: string;
image: string;
isPublicDashboard: boolean;
isDashboardLocked: boolean;
}
function DashboardTitle({
title,
image,
isPublicDashboard,
isDashboardLocked,
}: Props): JSX.Element {
return (
<div className={styles.leftSection}>
<img src={image} alt="dashboard-img" className={styles.dashboardImg} />
<TooltipSimple title={title.length > 30 ? title : ''}>
<Typography.Text
className={styles.dashboardTitle}
data-testid="dashboard-title"
>
{title}
</Typography.Text>
</TooltipSimple>
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} className={styles.publicDashboardIcon} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
</div>
);
}
export default DashboardTitle;

View File

@@ -0,0 +1,70 @@
import { Input, Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Check, X } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from '../DashboardDescription.module.scss';
interface Props {
open: boolean;
value: string;
isLoading: boolean;
onChange: (value: string) => void;
onRename: () => void;
onClose: () => void;
}
function RenameDashboardModal({
open,
value,
isLoading,
onChange,
onRename,
onClose,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Rename Dashboard"
onOk={onRename}
onCancel={onClose}
rootClassName={styles.renameDashboard}
footer={
<div className={styles.dashboardRename}>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
className={styles.renameBtn}
onClick={onRename}
disabled={isLoading}
>
Rename Dashboard
</Button>
<Button
variant="ghost"
prefix={<X size={14} />}
className={styles.cancelBtn}
onClick={onClose}
>
Cancel
</Button>
</div>
}
>
<div className={styles.dashboardContent}>
<Typography.Text className={styles.nameText}>
Enter a new name
</Typography.Text>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
</div>
</Modal>
);
}
export default RenameDashboardModal;

View File

@@ -0,0 +1,159 @@
import { useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { Card } from 'antd';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
lockDashboardV2,
patchDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardMeta from './DashboardMeta/DashboardMeta';
import DashboardTitle from './DashboardTitle/DashboardTitle';
import RenameDashboardModal from './RenameDashboardModal/RenameDashboardModal';
import styles from './DashboardDescription.module.scss';
interface DashboardDescriptionProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
refetch: () => void;
}
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { dashboard, handle, refetch } = props;
const id = dashboard.id;
const isDashboardLocked = !!dashboard.locked;
const title = dashboard.spec?.display?.name ?? '';
const description = dashboard.spec?.display?.description ?? '';
const image = dashboard.image || Base64Icons[0];
const tags = useMemo(
() =>
(dashboard.tags ?? []).map((t) =>
t.key === t.value ? t.key : `${t.key}:${t.value}`,
),
[dashboard.tags],
);
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const { showErrorModal } = useErrorModal();
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
const addPanelPermission = !isDashboardLocked;
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
const isPublicDashboard = false;
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
useState<boolean>(false);
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
useEffect(() => {
setUpdatedTitle(title);
}, [title]);
const handleLockDashboardToggle = async (): Promise<void> => {
if (!id) {
return;
}
try {
if (isDashboardLocked) {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
} else {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
}
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
};
const onNameChangeHandler = async (): Promise<void> => {
const trimmed = updatedTitle.trim();
if (!id || !trimmed || trimmed === title) {
setIsRenameDashboardOpen(false);
return;
}
try {
setIsRenameLoading(true);
const patch: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: trimmed,
},
];
await patchDashboardV2({ id }, patch);
toast.success('Dashboard renamed successfully');
setIsRenameDashboardOpen(false);
refetch();
} catch (error) {
showErrorModal(error as APIError);
setIsRenameDashboardOpen(true);
} finally {
setIsRenameLoading(false);
}
};
const onEmptyWidgetHandler = (): void => {
void logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
toast.info('V2 panel editor coming next');
};
return (
<Card className={styles.dashboardDescriptionContainer}>
<DashboardHeader title={title} image={image} />
<section className={styles.dashboardDetails}>
<DashboardTitle
title={title}
image={image}
isPublicDashboard={isPublicDashboard}
isDashboardLocked={isDashboardLocked}
/>
<DashboardActions
dashboard={dashboard}
handle={handle}
isDashboardLocked={isDashboardLocked}
editDashboard={editDashboard}
isAuthor={isAuthor}
addPanelPermission={addPanelPermission}
onAddPanel={onEmptyWidgetHandler}
onLockToggle={handleLockDashboardToggle}
onOpenRename={(): void => setIsRenameDashboardOpen(true)}
/>
</section>
<DashboardMeta tags={tags} description={description} />
<RenameDashboardModal
open={isRenameDashboardOpen}
value={updatedTitle}
isLoading={isRenameLoading}
onChange={setUpdatedTitle}
onRename={onNameChangeHandler}
onClose={(): void => setIsRenameDashboardOpen(false)}
/>
</Card>
);
}
export default DashboardDescription;

View File

@@ -0,0 +1,52 @@
.panel {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 4px;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--bg-slate-400, #1d212d);
cursor: grab;
}
.headerLeft {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.headerTitle {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
margin-inline-end: 0;
}
.body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
color: var(--bg-vanilla-400, #8993ae);
font-size: 12px;
text-align: center;
}
.bodyKind {
margin-bottom: 6px;
}

View File

@@ -0,0 +1,67 @@
import { useMemo } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import styles from './Panel.module.scss';
interface Props {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
* Placeholder: true once this panel's section enters the viewport. The panel
* query-loading implementation (later PR) will consume this to lazily fetch
* data. Currently unused on purpose.
*/
isVisible?: boolean;
}
function Panel({ panel, panelId, isVisible }: Props): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel?.spec?.queries?.length ?? 0;
const headerTitle = useMemo(() => {
if (!description) {
return name;
}
return (
<TooltipSimple title={description}>
<span>{name}</span>
</TooltipSimple>
);
}, [name, description]);
return (
<div
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
>
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>
{headerTitle}
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
<EllipsisVertical size={14} />
</div>
<div className={styles.body}>
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
coming next
</div>
</div>
</div>
</div>
);
}
export default Panel;

View File

@@ -0,0 +1,10 @@
.body {
flex: 1;
padding: 12px 24px;
overflow: auto;
}
.emptyState {
padding: 48px;
text-align: center;
}

View File

@@ -0,0 +1,9 @@
.section {
margin-bottom: 12px;
border: 1px solid var(--bg-slate-500);
border-radius: 4px;
}
.dragging {
opacity: 0.8;
}

View File

@@ -0,0 +1,60 @@
import { useRef, useState } from 'react';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import type { DashboardSection } from '../../../utils';
import SectionGrid from '../SectionGrid/SectionGrid';
import SectionHeader from '../SectionHeader/SectionHeader';
import styles from './Section.module.scss';
interface Props {
section: DashboardSection;
}
function Section({ section }: Props): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
// true once the section scrolls into (or near) the viewport.
const isVisible = useIntersectionObserver(containerRef, {
rootMargin: '200px',
});
const [open, setOpen] = useState<boolean>(section.open);
const toggle = (): void => setOpen((prev) => !prev);
const grid = <SectionGrid items={section.items} isVisible={isVisible} />;
if (!section.title) {
// Untitled section — just the grid (no header chrome), but still observed
// for the viewport signal.
return (
<div
ref={containerRef}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
{grid}
</div>
);
}
return (
<div
ref={containerRef}
className={styles.section}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
<SectionHeader
sectionId={section.id}
title={section.title}
open={open}
onToggle={toggle}
repeatVariable={section.repeatVariable}
/>
{open ? grid : null}
</div>
);
}
export default Section;

View File

@@ -0,0 +1,12 @@
.grid {
// Override react-grid-layout's default red drag/resize placeholder with the
// SigNoz brand blue.
:global(.react-grid-item.react-grid-placeholder) {
background: var(--bg-robin-500);
opacity: 0.2;
border-radius: 4px;
transition-duration: 100ms;
z-index: 2;
user-select: none;
}
}

View File

@@ -0,0 +1,50 @@
import { useMemo } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import type { DashboardSection } from '../../../utils';
import Panel from '../../Panel/Panel';
import styles from './SectionGrid.module.scss';
const ResponsiveGridLayout = WidthProvider(GridLayout);
interface Props {
items: DashboardSection['items'];
/** Forwarded to panels — true when the parent section is in the viewport. */
isVisible?: boolean;
}
function SectionGrid({ items, isVisible }: Props): JSX.Element {
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.width,
h: item.height,
})),
[items],
);
return (
<ResponsiveGridLayout
className={styles.grid}
cols={12}
rowHeight={45}
autoSize
useCSSTransforms
layout={rglLayout}
isDraggable={false}
isResizable={false}
margin={[8, 8]}
>
{items.map((item) => (
<div key={item.id}>
<Panel panel={item.panel} panelId={item.id} isVisible={isVisible} />
</div>
))}
</ResponsiveGridLayout>
);
}
export default SectionGrid;

View File

@@ -0,0 +1,52 @@
.header {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
&.headerOpen {
border-bottom: 1px solid var(--bg-slate-500);
}
}
.dragHandle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: none;
color: var(--bg-vanilla-400, #8993ae);
cursor: grab;
&:active {
cursor: grabbing;
}
}
.toggle {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
padding: 0;
background: transparent;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
min-width: 0;
}
.title {
margin-left: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.repeatBadge {
margin-left: 8px;
opacity: 0.6;
}

View File

@@ -0,0 +1,42 @@
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './SectionHeader.module.scss';
interface Props {
sectionId: string;
title: string;
open: boolean;
onToggle: () => void;
repeatVariable?: string;
}
function SectionHeader({
sectionId,
title,
open,
onToggle,
repeatVariable,
}: Props): JSX.Element {
return (
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
<button
type="button"
className={styles.toggle}
onClick={onToggle}
data-testid={`dashboard-section-toggle-${sectionId}`}
>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<Typography.Text className={styles.title}>{title}</Typography.Text>
{repeatVariable ? (
<Typography.Text className={styles.repeatBadge}>
(repeats per ${repeatVariable})
</Typography.Text>
) : null}
</button>
</div>
);
}
export default SectionHeader;

View File

@@ -0,0 +1,53 @@
import { ReactNode, useMemo } from 'react';
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { layoutsToSections } from '../utils';
import Section from './Section/Section/Section';
import styles from './PanelsAndSectionsLayout.module.scss';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
interface Props {
layouts: DashboardtypesLayoutDTO[];
panels: Record<string, DashboardtypesPanelDTO | undefined>;
}
function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
const sections = useMemo(
() => layoutsToSections(layouts, panels),
[layouts, panels],
);
const isEmpty =
sections.length === 0 || sections.every((s) => s.items.length === 0);
const renderContent = (): ReactNode => {
if (isEmpty) {
return (
<div className={styles.emptyState}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Typography.Text>No panels in this dashboard yet</Typography.Text>
}
/>
</div>
);
}
return sections.map((section) => (
<Section key={section.id} section={section} />
));
};
return <div className={styles.body}>{renderContent()}</div>;
}
export default PanelsAndSectionsLayout;

View File

@@ -0,0 +1,63 @@
.dashboardBreadcrumbs {
width: 100%;
height: 48px;
display: flex;
gap: 6px;
align-items: center;
max-width: 80%;
.dashboardBtn {
display: flex;
align-items: center;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding: 0px;
height: 20px;
}
.dashboardBtn:hover {
background-color: unset;
}
.idBtn {
display: flex;
align-items: center;
gap: 4px;
padding: 0px 2px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
height: 20px;
max-width: calc(100% - 120px);
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.ant-btn-icon) {
margin-inline-end: 4px;
}
}
.idBtn:hover {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-300);
}
.dashboardIconImage {
height: 14px;
width: 14px;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
import { useMemo } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import DashboardDescription from './DashboardDescription';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import styles from './DashboardContainer.module.scss';
interface Props {
dashboard: DashboardtypesGettableDashboardV2DTO;
refetch: () => void;
}
function DashboardContainer({ dashboard, refetch }: Props): JSX.Element {
const fullScreenHandle = useFullScreenHandle();
const { spec } = dashboard;
const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]);
const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]);
return (
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>
<DashboardDescription
dashboard={dashboard}
handle={fullScreenHandle}
refetch={refetch}
/>
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
</div>
</FullScreen>
);
}
export default DashboardContainer;

View File

@@ -0,0 +1,154 @@
import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
export interface GridItem {
id: string;
x: number;
y: number;
width: number;
height: number;
panel: DashboardtypesPanelDTO | undefined;
}
const PANEL_REF_PREFIX = '#/spec/panels/';
export function extractPanelIdFromRef(ref: string | undefined): string | null {
if (!ref) {
return null;
}
if (!ref.startsWith(PANEL_REF_PREFIX)) {
return null;
}
return ref.slice(PANEL_REF_PREFIX.length);
}
export function flattenGridLayout(
layouts: DashboardtypesLayoutDTO[] | undefined | null,
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
): GridItem[] {
if (!layouts?.length) {
return [];
}
const items: GridItem[] = [];
layouts.forEach((layoutEnvelope) => {
if (layoutEnvelope?.kind !== 'Grid') {
return;
}
const gridItems = layoutEnvelope.spec?.items ?? [];
gridItems.forEach((item) => {
const id = extractPanelIdFromRef(item.content?.$ref);
if (!id) {
return;
}
items.push({
id,
x: item.x ?? 0,
y: item.y ?? 0,
width: item.width ?? 6,
height: item.height ?? 6,
panel: panels?.[id],
});
});
});
return items;
}
/**
* A section corresponds to one entry in `spec.layouts`. If the Grid has a
* `display.title`, it renders with a collapsible header; otherwise it is a
* "default" untitled section (visually just the grid).
*/
export interface DashboardSection {
/**
* Stable identity used for React keys and dnd-kit sortable item ids. Derived
* from the section's content (its first panel ref) so it survives reordering
* — unlike the positional `layoutIndex`. See `getSectionStableId`.
*/
id: string;
/** Position of this section's Grid in `spec.layouts`. All JSON-Patch ops target by this. */
layoutIndex: number;
title: string | undefined;
open: boolean;
items: GridItem[];
repeatVariable: string | undefined;
}
/**
* Derives a stable id for a section from its content. Reordering sections changes
* their `layoutIndex` but not their content, so keying off the first panel ref
* keeps React component instances (and any local state) bound to the right
* section across a reorder. Empty sections fall back to a positional id — they
* are rarely reordered, and a future backend `id` on the layout spec is the
* proper long-term fix.
*/
export function getSectionStableId(
items: GridItem[],
layoutIndex: number,
): string {
if (items.length > 0) {
return `sec-${items[0].id}`;
}
return `sec-empty-${layoutIndex}`;
}
export function layoutsToSections(
layouts: DashboardtypesLayoutDTO[] | undefined | null,
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
): DashboardSection[] {
if (!layouts?.length) {
return [];
}
return layouts
.map((layoutEnvelope, idx) => {
if (layoutEnvelope?.kind !== 'Grid') {
return null;
}
const spec = layoutEnvelope.spec;
const items: GridItem[] = (spec?.items ?? [])
.map((item) => {
const id = extractPanelIdFromRef(item.content?.$ref);
if (!id) {
return null;
}
return {
id,
x: item.x ?? 0,
y: item.y ?? 0,
width: item.width ?? 6,
height: item.height ?? 6,
panel: panels?.[id],
};
})
.filter((it): it is GridItem => it !== null);
const title = spec?.display?.title;
// `open` defaults to true when no collapse field is set (the section
// is expanded by default).
const open = spec?.display?.collapse?.open !== false;
return {
id: getSectionStableId(items, idx),
layoutIndex: idx,
title,
open,
items,
repeatVariable: spec?.repeatVariable,
};
})
.filter((s): s is DashboardSection => s !== null);
}
export function getPanelKindLabel(
panel: DashboardtypesPanelDTO | undefined,
): string {
const kind = panel?.spec?.plugin?.kind;
if (!kind) {
return 'unknown';
}
return kind.replace(/^signoz\//, '');
}

View File

@@ -0,0 +1,3 @@
.errorState {
padding: 24px;
}

View File

@@ -1,5 +1,43 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
import Spinner from 'components/Spinner';
import DashboardContainer from './DashboardContainer';
import styles from './DashboardPageV2.module.scss';
function DashboardPageV2(): JSX.Element {
return <>DashboardPageV2</>;
const { dashboardId } = useParams<{ dashboardId: string }>();
const { data, isLoading, isError, error, refetch } = useGetDashboardV2({
id: dashboardId,
});
const dashboard = data?.data;
const name = dashboard?.spec?.display?.name;
useEffect(() => {
if (name) {
document.title = name;
}
}, [name]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
}
if (isError || !dashboard) {
return (
<div className={styles.errorState}>
<Typography.Title>Failed to load dashboard</Typography.Title>
<Typography.Text>{(error as Error)?.message}</Typography.Text>
</div>
);
}
return <DashboardContainer dashboard={dashboard} refetch={refetch} />;
}
export default DashboardPageV2;

1
go.mod
View File

@@ -18,7 +18,6 @@ require (
github.com/dgraph-io/ristretto/v2 v2.3.0
github.com/dustin/go-humanize v1.0.1
github.com/emersion/go-smtp v0.24.0
github.com/evanphx/json-patch/v5 v5.9.11
github.com/gin-gonic/gin v1.11.0
github.com/go-co-op/gocron v1.30.1
github.com/go-openapi/runtime v0.29.2

2
go.sum
View File

@@ -311,8 +311,6 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQ
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:IT4JYU7k4ikYg1SCxNI1/Tieq/NFvh6dzLdgi7eu0tM=
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=

View File

@@ -24,10 +24,9 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
// TODO: add http.StatusConflict once the dashboard name unique index is added.
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -42,87 +41,13 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateV2), handler.OpenAPIDef{
ID: "UpdateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Update dashboard (v2)",
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
Request: new(dashboardtypes.UpdateableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.PatchV2), handler.OpenAPIDef{
ID: "PatchDashboardV2",
Tags: []string{"dashboard"},
Summary: "Patch dashboard (v2)",
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.",
Request: new(dashboardtypes.PatchableDashboardV2),
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
// but our OpenAPI generator only reflects schemas for content types it
// understands (application/json, form-urlencoded, multipart) — anything
// else degrades to `type: string`. Declaring application/json here keeps
// the array-of-ops schema visible to spec consumers; the runtime decoder
// parses JSON regardless of the request's actual Content-Type header.
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
ID: "LockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Lock dashboard (v2)",
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UnlockV2), handler.OpenAPIDef{
ID: "UnlockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unlock dashboard (v2)",
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
ID: "CreatePublicDashboard",
Tags: []string{"dashboard"},

View File

@@ -0,0 +1 @@
<svg id="b70acf0a-34b4-4bdf-9024-7496043ff915" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><radialGradient id="e2cf8746-c9a8-4eee-86c2-4951983c6032" cx="13428.81" cy="3518.86" r="56.67" gradientTransform="translate(-2005.33 -518.83) scale(0.15)" gradientUnits="userSpaceOnUse"><stop offset="0.18" stop-color="#5ea0ef"/><stop offset="1" stop-color="#0078d4"/></radialGradient><linearGradient id="bdd213dd-d313-473c-8ff4-0133fd3a9033" x1="4.4" y1="11.48" x2="4.37" y2="7.53" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient><linearGradient id="afcc63c5-3649-4476-a742-bcb53a569f3c" x1="10.13" y1="15.45" x2="10.13" y2="11.9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient><linearGradient id="bd873f0b-9954-4aa5-a3df-9f4c64e8729d" x1="14.18" y1="11.15" x2="14.18" y2="7.38" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient></defs><title>Icon-web-41</title><path id="ee75dd06-1aca-4f76-9d11-d05a284020ad" d="M14.21,15.72A8.5,8.5,0,0,1,3.79,2.28l.09-.06a8.5,8.5,0,0,1,10.33,13.5" fill="url(#e2cf8746-c9a8-4eee-86c2-4951983c6032)"/><path d="M6.69,7.23A13,13,0,0,1,15.6,3.65a8.47,8.47,0,0,0-1.49-1.44,14.34,14.34,0,0,0-4.69,1.1A12.54,12.54,0,0,0,5.34,6.13,2.76,2.76,0,0,1,6.69,7.23Z" fill="#fff" opacity="0.6"/><path d="M2.48,10.65a17.86,17.86,0,0,0-.83,2.62,7.82,7.82,0,0,0,.62.92c.18.23.35.44.55.65A17.94,17.94,0,0,1,3.9,11.37,2.76,2.76,0,0,1,2.48,10.65Z" fill="#fff" opacity="0.6"/><path d="M3.46,6.11a12,12,0,0,1-.69-2.94,8.15,8.15,0,0,0-1.1,1.45A12.69,12.69,0,0,0,2.24,7,2.69,2.69,0,0,1,3.46,6.11Z" fill="#f2f2f2" opacity="0.55"/><circle cx="4.38" cy="8.68" r="2.73" fill="url(#bdd213dd-d313-473c-8ff4-0133fd3a9033)"/><path d="M8.36,13.67A1.77,1.77,0,0,1,8.9,12.4a11.88,11.88,0,0,1-2.53-1.86,2.74,2.74,0,0,1-1.49.83,13.1,13.1,0,0,0,1.45,1.28A12.12,12.12,0,0,0,8.38,13.9,1.79,1.79,0,0,1,8.36,13.67Z" fill="#f2f2f2" opacity="0.55"/><path d="M14.66,13.88a12,12,0,0,1-2.76-.32.41.41,0,0,1,0,.11,1.75,1.75,0,0,1-.51,1.24,13.69,13.69,0,0,0,3.42.24A8.21,8.21,0,0,0,16,13.81,11.5,11.5,0,0,1,14.66,13.88Z" fill="#f2f2f2" opacity="0.55"/><circle cx="10.13" cy="13.67" r="1.78" fill="url(#afcc63c5-3649-4476-a742-bcb53a569f3c)"/><path d="M12.32,8.93a1.83,1.83,0,0,1,.61-1A25.5,25.5,0,0,1,8.47,3.79a16.91,16.91,0,0,1-2-2.92,7.64,7.64,0,0,0-1.09.42A18.14,18.14,0,0,0,7.53,4.47,26.44,26.44,0,0,0,12.32,8.93Z" fill="#f2f2f2" opacity="0.7"/><circle cx="14.18" cy="9.27" r="1.89" fill="url(#bd873f0b-9954-4aa5-a3df-9f4c64e8729d)"/><path d="M17.35,10.54,17,10.37l0,0-.3-.16-.06,0L16.38,10l-.07,0L16,9.8a1.76,1.76,0,0,1-.64.92c.12.08.25.15.38.22l.08.05.35.19,0,0,.86.45h0a8.63,8.63,0,0,0,.29-1.11Z" fill="#f2f2f2" opacity="0.55"/><circle cx="4.38" cy="8.68" r="2.73" fill="url(#bdd213dd-d313-473c-8ff4-0133fd3a9033)"/><circle cx="10.13" cy="13.67" r="1.78" fill="url(#afcc63c5-3649-4476-a742-bcb53a569f3c)"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,272 @@
{
"id": "appservice",
"title": "App Services",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_averagememoryworkingset_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_bytesreceived_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_bytessent_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_backendrequestcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cputime_count",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cputime_total",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cputime_minimum",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cputime_maximum",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_currentassemblies_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_filesystemusage_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_gen0collections_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_ge10collections_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_gen2collections_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_handles_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_healthcheckstatus_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http101_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http2xx_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http3xx_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http401_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http403_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http404_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http406_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http4xx_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http5xx_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_httpresponsetime_average",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_iootherbytespersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_iootheroperationspersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_ioreadbytespersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_ioreadoperationspersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_iowritebytespersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_iowriteoperationspersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_privatebytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_requests_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_requestsinapplicationqueue_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_thread_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalappdomains_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalappdomainsunloaded_average",
"unit": "Count",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.Web",
"resourceType": "sites",
"metrics": {},
"logs": {
"categoryGroups": ["allLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "App Services Overview",
"description": "Overview of App Services metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,5 @@
### Monitor Azure App Services with SigNoz
Collect key App Services metrics and view them with an out of the box dashboard.
Note: This integration DO NOT collect metrics for any database that was setup with your App Service (if any).

View File

@@ -60,12 +60,6 @@ type Module interface {
CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error)
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error)
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
}
type Handler interface {
@@ -95,12 +89,4 @@ type Handler interface {
CreateV2(http.ResponseWriter, *http.Request)
GetV2(http.ResponseWriter, *http.Request)
UpdateV2(http.ResponseWriter, *http.Request)
LockV2(http.ResponseWriter, *http.Request)
UnlockV2(http.ResponseWriter, *http.Request)
PatchV2(http.ResponseWriter, *http.Request)
}

View File

@@ -153,7 +153,7 @@ func (store *store) ListPublic(ctx context.Context, orgID valuer.UUID) ([]*dashb
func (store *store) Update(ctx context.Context, orgID valuer.UUID, storableDashboard *dashboardtypes.StorableDashboard) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
BunDB().
NewUpdate().
Model(storableDashboard).
WherePK().

View File

@@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
@@ -73,147 +72,3 @@ func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, true)
}
func (handler *handler) UnlockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, false)
}
func (handler *handler) lockUnlockV2(rw http.ResponseWriter, r *http.Request, lock bool) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
isAdmin := false
selectors := []coretypes.Selector{
coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName),
}
err = handler.authz.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.Relation{Verb: coretypes.VerbAssignee},
coretypes.NewResourceRole(),
selectors,
selectors,
)
if err == nil {
isAdmin = true
}
if err := handler.module.LockUnlockV2(ctx, orgID, dashboardID, claims.Email, isAdmin, lock); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) UpdateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.UpdateableDashboardV2{}
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.UpdateV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) PatchV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.PatchableDashboardV2{}
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.PatchV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}

View File

@@ -55,97 +55,3 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return storable.ToDashboardV2(tags)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updateable.Validate(); err != nil {
return nil, err
}
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Tags)
if err != nil {
return err
}
err = existing.Update(updateable, updatedBy, resolvedTags)
if err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storable)
})
if err != nil {
return nil, err
}
return existing, nil
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
return nil, err
}
updateable, err := patch.Apply(existing)
if err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Tags)
if err != nil {
return err
}
err = existing.Update(*updateable, updatedBy, resolvedTags)
if err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storable)
})
if err != nil {
return nil, err
}
return existing, nil
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return err
}
if err := existing.LockUnlock(lock, isAdmin, updatedBy); err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storable)
}

View File

@@ -19,7 +19,7 @@ type WaterfallConfig struct {
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("tracedetail"), newConfig)
return factory.NewConfigFactory(factory.MustNewName("traces"), newConfig)
}
func newConfig() factory.Config {
@@ -34,16 +34,13 @@ func newConfig() factory.Config {
func (c Config) Validate() error {
if c.Waterfall.SpanPageSize <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.waterfall.span_limit_per_request must be positive, got %v", c.Waterfall.SpanPageSize)
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.span_limit_per_request must be positive, got %v", c.Waterfall.SpanPageSize)
}
if c.Waterfall.MaxDepthToAutoExpand < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.waterfall.max_depth_for_selected_children cannot be negative, got %d", c.Waterfall.MaxDepthToAutoExpand)
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.max_depth_for_selected_children cannot be negative, got %d", c.Waterfall.MaxDepthToAutoExpand)
}
if c.Waterfall.MaxLimitToSelectAllSpans == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.waterfall.max_limit_to_select_all_spans must be positive")
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.max_limit_to_select_all_spans must be positive")
}
return nil
}

View File

@@ -7,21 +7,34 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"go.opentelemetry.io/otel/metric"
)
type module struct {
store spantypes.TraceStore
settings factory.ScopedProviderSettings
config tracedetail.Config
metrics *moduleMetrics
}
func NewModule(traceStore spantypes.TraceStore, providerSettings factory.ProviderSettings, cfg tracedetail.Config) *module {
scopedProviderSettings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail")
return &module{
metrics, err := newModuleMetrics(scopedProviderSettings.Meter())
if err != nil {
panic(err)
}
m := &module{
config: cfg,
store: traceStore,
settings: scopedProviderSettings,
metrics: metrics,
}
m.metrics.waterfallSpanLimit.Record(context.Background(), int64(cfg.Waterfall.MaxLimitToSelectAllSpans), metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed)))
return m
}
func (m *module) GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error) {
@@ -80,6 +93,9 @@ func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpa
}
effectiveLimit := min(selectAllLimit, m.config.Waterfall.MaxLimitToSelectAllSpans)
if summary.NumSpans > uint64(effectiveLimit) {
attrs := metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed))
m.metrics.waterfallRequestCount.Add(ctx, 1, attrs)
m.metrics.waterfallSpanCount.Add(ctx, int64(summary.NumSpans), attrs)
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, summary.Start, summary.End)
}
return m.getFullWaterfall(ctx, traceID, summary)

View File

@@ -0,0 +1,55 @@
package impltracedetail
import (
"github.com/SigNoz/signoz/pkg/errors"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
const (
attrResponseType = attribute.Key("response_type")
attrResponseTypeWindowed = "windowed"
)
type moduleMetrics struct {
waterfallSpanLimit metric.Int64Gauge
waterfallRequestCount metric.Int64Counter
waterfallSpanCount metric.Int64Counter
}
func newModuleMetrics(meter metric.Meter) (*moduleMetrics, error) {
var errs error
spanLimit, err := meter.Int64Gauge(
"signoz.traces.waterfall.span.limit",
metric.WithDescription("The span count limit above which windowed waterfall is returned instead of the full waterfall."),
metric.WithUnit("{span}"),
)
if err != nil {
errs = errors.Join(errs, err)
}
requestCount, err := meter.Int64Counter(
"signoz.traces.waterfall.request.count",
metric.WithDescription("Total number of waterfall requests, by response_type."),
metric.WithUnit("{request}"),
)
if err != nil {
errs = errors.Join(errs, err)
}
spanCount, err := meter.Int64Counter(
"signoz.traces.waterfall.span.count",
metric.WithDescription("Total number of spans across waterfall requests, by response_type."),
metric.WithUnit("{span}"),
)
if err != nil {
errs = errors.Join(errs, err)
}
return &moduleMetrics{
waterfallSpanLimit: spanLimit,
waterfallRequestCount: requestCount,
waterfallSpanCount: spanCount,
}, errs
}

View File

@@ -143,7 +143,7 @@ type Config struct {
CloudIntegration cloudintegration.Config `mapstructure:"cloudintegration"`
// TraceDetail config
TraceDetail tracedetail.Config `mapstructure:"tracedetail"`
TraceDetail tracedetail.Config `mapstructure:"traces"`
// Authz config
Authz authz.Config `mapstructure:"authz"`

View File

@@ -27,6 +27,7 @@ var (
// Azure services.
AzureServiceStorageAccountsBlob = ServiceID{valuer.NewString("storageaccountsblob")}
AzureServiceCDNProfile = ServiceID{valuer.NewString("cdnprofile")}
AzureServiceAppService = ServiceID{valuer.NewString("appservice")}
)
func (ServiceID) Enum() []any {
@@ -46,6 +47,7 @@ func (ServiceID) Enum() []any {
AWSServiceSQS,
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceAppService,
}
}
@@ -69,6 +71,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
CloudProviderTypeAzure: {
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceAppService,
},
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
jsonpatch "github.com/evanphx/json-patch/v5"
"github.com/perses/perses/pkg/model/api/v1/common"
"k8s.io/apimachinery/pkg/util/validation"
)
@@ -63,54 +62,6 @@ type DashboardV2 struct {
Spec DashboardSpec `json:"spec" required:"true"`
}
func (d *DashboardV2) CanUpdate() error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
}
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
}
return nil
}
func (d *DashboardV2) Update(updateable UpdateableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
if err := d.CanUpdate(); err != nil {
return err
}
if updateable.Name != d.Name {
return errors.NewInvalidInputf(ErrCodeDashboardImmutable, "name is immutable; cannot change from %q to %q", d.Name, updateable.Name)
}
d.DashboardV2MetadataBase = updateable.DashboardV2MetadataBase
d.Tags = resolvedTags
d.Spec = updateable.Spec
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be locked or unlocked")
}
if d.Source == SourceSystem {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "system dashboards cannot be locked or unlocked")
}
if d.CreatedBy != updatedBy && !isAdmin {
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
}
return nil
}
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
if err := d.CanLockUnlock(isAdmin, updatedBy); err != nil {
return err
}
d.Locked = lock
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
type DashboardV2MetadataBase struct {
SchemaVersion string `json:"schemaVersion" required:"true"`
Image string `json:"image,omitempty"`
@@ -175,7 +126,7 @@ func (p *PostableDashboardV2) Validate() error {
if err := p.validateName(); err != nil {
return err
}
if err := validateDashboardTags(p.Tags); err != nil {
if err := p.validateTags(); err != nil {
return err
}
return p.Spec.Validate()
@@ -242,11 +193,11 @@ func generateDashboardName(displayName string) string {
return prefix + "-" + string(suffix)
}
func validateDashboardTags(tags []tagtypes.PostableTag) error {
if len(tags) > MaxTagsPerDashboard {
func (p *PostableDashboardV2) validateTags() error {
if len(p.Tags) > MaxTagsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard)
}
for _, tag := range tags {
for _, tag := range p.Tags {
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(strings.TrimSpace(tag.Key)))]; reserved {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "tag key %q is reserved", tag.Key)
}
@@ -312,129 +263,6 @@ func (s StorableDashboardV2Data) toStorableDashboardData() (StorableDashboardDat
type StorableDashboardV2Metadata = DashboardV2MetadataBase
// ════════════════════════════════════════════════════════════════════════
// Updateable
// ════════════════════════════════════════════════════════════════════════
type UpdateableDashboardV2 struct {
DashboardV2MetadataBase
Name string `json:"name" required:"true"`
Tags []tagtypes.PostableTag `json:"tags" required:"true"`
Spec DashboardSpec `json:"spec" required:"true"`
}
func (u *UpdateableDashboardV2) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias UpdateableDashboardV2
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*u = UpdateableDashboardV2(tmp)
if u.Spec.Display == nil {
u.Spec.Display = &common.Display{}
}
if u.Spec.Display.Name == "" {
u.Spec.Display.Name = u.Name
}
return u.Validate()
}
func (u *UpdateableDashboardV2) Validate() error {
if u.SchemaVersion != SchemaVersion {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "schemaVersion must be %q, got %q", SchemaVersion, u.SchemaVersion)
}
if err := validateDashboardName(u.Name); err != nil {
return err
}
if err := validateDashboardTags(u.Tags); err != nil {
return err
}
return u.Spec.Validate()
}
func (d DashboardV2) toUpdateableDashboardV2() UpdateableDashboardV2 {
return UpdateableDashboardV2{
DashboardV2MetadataBase: d.DashboardV2MetadataBase,
Name: d.Name,
Tags: tagtypes.NewPostableTagsFromTags(d.Tags),
Spec: d.Spec,
}
}
// ════════════════════════════════════════════════════════════════════════
// Patchable
// ════════════════════════════════════════════════════════════════════════
type PatchableDashboardV2 []JSONPatchOperation
type JSONPatchOperation struct {
Op PatchOp `json:"op" required:"true"`
Path string `json:"path" required:"true" description:"JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-."`
// `value` is required for add/replace/test.
Value any `json:"value,omitempty" description:"Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy."`
// `from` is required for move/copy.
From string `json:"from,omitempty" description:"Source JSON Pointer for move/copy ops; ignored for other ops."`
}
// PatchOp covers the six RFC 6902 JSON Patch verbs.
type PatchOp struct{ valuer.String }
var (
PatchOpAdd = PatchOp{valuer.NewString("add")}
PatchOpRemove = PatchOp{valuer.NewString("remove")}
PatchOpReplace = PatchOp{valuer.NewString("replace")}
PatchOpMove = PatchOp{valuer.NewString("move")}
PatchOpCopy = PatchOp{valuer.NewString("copy")}
PatchOpTest = PatchOp{valuer.NewString("test")}
)
func (PatchOp) Enum() []any {
return []any{PatchOpAdd, PatchOpRemove, PatchOpReplace, PatchOpMove, PatchOpCopy, PatchOpTest}
}
func (p *PatchableDashboardV2) UnmarshalJSON(data []byte) error {
// DecodePatch rejects unknown verbs, add/replace ops missing a value, move/copy ops missing a
// from, and malformed paths — so callers get an InvalidInput error up front rather
// than deep inside Apply.
if _, err := jsonpatch.DecodePatch(data); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
type alias PatchableDashboardV2
var ops alias
if err := json.Unmarshal(data, &ops); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*p = PatchableDashboardV2(ops)
return nil
}
func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdateableDashboardV2, error) {
existingAsUpdateable := existing.toUpdateableDashboardV2()
raw, err := json.Marshal(existingAsUpdateable)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal existing dashboard for patch")
}
rawPatch, err := json.Marshal(p)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal patch document")
}
patch, err := jsonpatch.DecodePatch(rawPatch)
if err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
patched, err := patch.ApplyWithOptions(raw, &jsonpatch.ApplyOptions{AllowMissingPathOnRemove: true, EnsurePathExistsOnAdd: true})
if err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
out := &UpdateableDashboardV2{}
if err := json.Unmarshal(patched, out); err != nil {
return nil, err
}
return out, nil
}
// ════════════════════════════════════════════════════════════════════════
// Convertors
// ════════════════════════════════════════════════════════════════════════

View File

@@ -1,569 +0,0 @@
package dashboardtypes
import (
"encoding/json"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// basePostableJSON is the postable shape of a small but realistic v2
// dashboard used as the base document for patch tests. Each panel carries
// one builder query in the same shape production dashboards use
// (aggregations, filter, groupBy populated), and the dashboard has one
// variable — the variable is not patched in any test here, that's
// covered in a separate variable-focused suite.
const basePostableJSON = `{
"schemaVersion": "v6",
"name": "service-overview",
"tags": [{"key": "team", "value": "alpha"}, {"key": "env", "value": "prod"}],
"spec": {
"display": {"name": "Service overview"},
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "service",
"allowAllValue": true,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"}
}
}
}
],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}],
"filter": {"expression": "service.name IN $service"},
"groupBy": [{"name": "service.name", "fieldDataType": "string", "fieldContext": "tag"}]
}}}
}
]
}
},
"p2": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "X",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_latency_count",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}
]
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"display": {"title": "Row 1"},
"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
]
}
}
],
"duration": "1h"
}
}`
func TestPatchableDashboardV2_Apply(t *testing.T) {
// Apply doesn't mutate the input *DashboardV2 — it marshals it to
// JSON, applies the patch, and unmarshals the result into a fresh
// struct. Sharing one base across subtests is safe.
var p PostableDashboardV2
require.NoError(t, json.Unmarshal([]byte(basePostableJSON), &p), "base postable JSON must validate")
testOrgID := valuer.GenerateUUID()
base := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
base.Tags = []*tagtypes.Tag{
{Key: "team", Value: "alpha"},
{Key: "env", Value: "prod"},
}
decode := func(t *testing.T, body string) PatchableDashboardV2 {
t.Helper()
var patch PatchableDashboardV2
require.NoError(t, json.Unmarshal([]byte(body), &patch))
return patch
}
// jsonOf marshals the patched dashboard back to JSON so subtests can
// assert on field values without reaching into the typed plugin specs.
jsonOf := func(t *testing.T, out *UpdateableDashboardV2) string {
t.Helper()
raw, err := json.Marshal(out)
require.NoError(t, err)
return string(raw)
}
// ─────────────────────────────────────────────────────────────────
// Successful patches
// ─────────────────────────────────────────────────────────────────
t.Run("no-op preserves all fields", func(t *testing.T) {
out, err := decode(t, `[]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, base.DashboardV2MetadataBase, out.DashboardV2MetadataBase)
assert.Equal(t, tagtypes.NewPostableTagsFromTags(base.Tags), out.Tags)
assert.Equal(t, base.Spec.Display.Name, out.Spec.Display.Name)
require.Equal(t, len(base.Spec.Panels), len(out.Spec.Panels))
for k, panel := range base.Spec.Panels {
require.Contains(t, out.Spec.Panels, k)
assert.Equal(t, panel.Spec.Plugin.Kind, out.Spec.Panels[k].Spec.Plugin.Kind)
}
assert.Len(t, out.Tags, len(base.Tags))
assert.Len(t, out.Spec.Variables, len(base.Spec.Variables))
assert.Len(t, out.Spec.Layouts, len(base.Spec.Layouts))
})
t.Run("add metadata image", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/image", "value": "https://example.com/img.png"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "https://example.com/img.png", out.Image)
assert.Equal(t, SchemaVersion, out.SchemaVersion, "schemaVersion preserved")
})
t.Run("replace display name", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/display/name", "value": "Renamed"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Renamed", out.Spec.Display.Name)
})
// Per RFC 6902 § 4.1, `add` on an existing object member replaces the
// existing value rather than erroring — same effect as `replace`.
t.Run("add overwrites existing display name", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/spec/display/name", "value": "Overwritten"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Overwritten", out.Spec.Display.Name)
})
t.Run("add data refreshInterval", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/spec/refreshInterval", "value": "30s"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "30s", string(out.Spec.RefreshInterval))
})
t.Run("add panel leaves others untouched", func(t *testing.T) {
out, err := decode(t, `[{
"op": "add",
"path": "/spec/panels/p3",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}]
}}}
}]
}
}
}]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Spec.Panels, 3)
assert.Contains(t, out.Spec.Panels, "p3")
// Plugin specs round-trip through MarshalJSON which resolves defaults
// (e.g. timePreference → "global_time"), so compare the serialized
// shape rather than the in-memory structs to skip that normalization.
for _, id := range []string{"p1", "p2"} {
wantJSON, err := json.Marshal(base.Spec.Panels[id])
require.NoError(t, err)
gotJSON, err := json.Marshal(out.Spec.Panels[id])
require.NoError(t, err)
assert.JSONEq(t, string(wantJSON), string(gotJSON), "panel %s untouched", id)
}
})
t.Run("replace single panel", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p2",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}]
}
}
}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, PanelPluginKind("signoz/BarChartPanel"), out.Spec.Panels["p2"].Spec.Plugin.Kind)
assert.Equal(t, PanelPluginKind("signoz/TimeSeriesPanel"), out.Spec.Panels["p1"].Spec.Plugin.Kind, "p1 untouched")
})
// Removing a panel realistically also drops its layout item — exercise
// the multi-op shape the UI sends.
t.Run("remove panel and its layout item", func(t *testing.T) {
out, err := decode(t, `[
{"op": "remove", "path": "/spec/panels/p2"},
{"op": "remove", "path": "/spec/layouts/0/spec/items/1"}
]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Spec.Panels, 1)
assert.Contains(t, out.Spec.Panels, "p1")
assert.NotContains(t, out.Spec.Panels, "p2")
raw := jsonOf(t, out)
assert.NotContains(t, raw, `"$ref":"#/spec/panels/p2"`)
assert.Contains(t, raw, `"$ref":"#/spec/panels/p1"`)
})
// The headline use case: edit a single field of a single query inside
// one panel without re-sending any other part of the dashboard.
t.Run("rename single query inside panel", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1/spec/queries/0/spec/plugin/spec/name",
"value": "renamed"
}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Spec.Panels["p1"].Spec.Queries, 1)
assert.Contains(t, jsonOf(t, out), `"name":"renamed"`)
})
// Replace a query at a specific index — swaps query "A" out for "B"
// without re-sending the rest of the panel.
t.Run("replace query at index", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1/spec/queries/0",
"value": {
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "B",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_db_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}
}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Spec.Panels["p1"].Spec.Queries, 1)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"name":"B"`)
assert.NotContains(t, raw, `"name":"A"`)
})
// ─────────────────────────────────────────────────────────────────
// Layout edits
// ─────────────────────────────────────────────────────────────────
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
// The first item used to live at x=0, now lives at x=6.
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
})
t.Run("resize panel by editing layout width", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"width":12`)
})
t.Run("rename layout row title", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/display/title", "value": "Latency"}]`).Apply(base)
require.NoError(t, err)
assert.Contains(t, jsonOf(t, out), `"title":"Latency"`)
})
t.Run("append layout item", func(t *testing.T) {
out, err := decode(t, `[{
"op": "add",
"path": "/spec/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
}]`).Apply(base)
require.NoError(t, err)
// Item count went 2 → 3.
raw := jsonOf(t, out)
assert.Equal(t, 3, strings.Count(raw, `"$ref":"#/spec/panels/`))
})
// Composing add-panel + add-layout-item is the realistic shape of the
// "add a new chart to my dashboard" UI flow — exercise it end-to-end.
t.Run("add panel and corresponding layout item", func(t *testing.T) {
out, err := decode(t, `[
{
"op": "add",
"path": "/spec/panels/p3",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}]
}}}
}]
}
}
},
{
"op": "add",
"path": "/spec/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}
}
]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Spec.Panels, 3)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"$ref":"#/spec/panels/p3"`)
})
t.Run("append tag", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Tags, 3)
assert.Equal(t, "env", out.Tags[2].Key)
assert.Equal(t, "staging", out.Tags[2].Value)
})
t.Run("append tag when none exist", func(t *testing.T) {
noTagsBase := &DashboardV2{
DashboardV2MetadataBase: base.DashboardV2MetadataBase,
Name: base.Name,
Tags: nil,
Spec: base.Spec,
}
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "team", "value": "new"}}]`).Apply(noTagsBase)
require.NoError(t, err)
require.Len(t, out.Tags, 1)
assert.Equal(t, "team", out.Tags[0].Key)
assert.Equal(t, "new", out.Tags[0].Value)
})
t.Run("replace tag value", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/tags/0/value", "value": "beta"}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Tags, 2)
assert.Equal(t, "team", out.Tags[0].Key)
assert.Equal(t, "beta", out.Tags[0].Value)
assert.Equal(t, "env", out.Tags[1].Key, "tag at index 1 untouched")
assert.Equal(t, "prod", out.Tags[1].Value, "tag at index 1 untouched")
for _, tag := range out.Tags {
assert.NotEqual(t, "alpha", tag.Value, "old tag value must be gone")
}
})
t.Run("multiple ops applied in order", func(t *testing.T) {
out, err := decode(t, `[
{"op": "replace", "path": "/spec/display/name", "value": "Multi-step"},
{"op": "remove", "path": "/spec/panels/p2"},
{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}
]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Multi-step", out.Spec.Display.Name)
assert.Len(t, out.Spec.Panels, 1)
assert.Len(t, out.Tags, 3)
})
// `test` is an RFC 6902 precondition op: aborts the patch if the value
// at the path doesn't equal the supplied value. Used for optimistic
// concurrency. Here it matches, so the subsequent ops apply.
t.Run("test op passes", func(t *testing.T) {
out, err := decode(t, `[
{"op": "test", "path": "/spec/display/name", "value": "Service overview"},
{"op": "replace", "path": "/spec/display/name", "value": "Confirmed"}
]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Confirmed", out.Spec.Display.Name)
})
// ─────────────────────────────────────────────────────────────────
// Failure cases
// ─────────────────────────────────────────────────────────────────
t.Run("decode rejects non-array body", func(t *testing.T) {
var patch PatchableDashboardV2
err := json.Unmarshal([]byte(`{"op": "replace"}`), &patch)
require.Error(t, err)
})
t.Run("decode rejects malformed JSON", func(t *testing.T) {
var patch PatchableDashboardV2
// Outer json.Unmarshal rejects non-JSON before PatchableDashboardV2's
// UnmarshalJSON runs, so the error is a stdlib SyntaxError rather
// than the InvalidInput-classified wrap.
err := json.Unmarshal([]byte(`not json`), &patch)
require.Error(t, err)
})
// `test` precondition fails — the whole patch is rejected, including
// the subsequent replace.
t.Run("test op failure rejected", func(t *testing.T) {
_, err := decode(t, `[
{"op": "test", "path": "/spec/display/name", "value": "Wrong"},
{"op": "replace", "path": "/spec/display/name", "value": "Should not apply"}
]`).Apply(base)
require.Error(t, err)
})
// Lenient apply (AllowMissingPathOnRemove): removing a path that doesn't
// exist is a no-op rather than an error, so removes are idempotent.
t.Run("remove at missing path is a no-op", func(t *testing.T) {
out, err := decode(t, `[{"op": "remove", "path": "/spec/panels/does-not-exist"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, len(base.Spec.Panels), len(out.Spec.Panels), "existing panels untouched")
})
t.Run("remove schemaVersion rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "remove", "path": "/schemaVersion"}]`).Apply(base)
require.Error(t, err)
})
t.Run("wrong schemaVersion rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "replace", "path": "/schemaVersion", "value": "v5"}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), SchemaVersion)
})
t.Run("empty display name defaults to dashboard name", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/display/name", "value": ""}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, base.Name, out.Spec.Display.Name, "empty display.name should default from name")
})
t.Run("unknown top-level field rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "add", "path": "/bogus", "value": 42}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "bogus")
})
t.Run("invalid panel kind rejected", func(t *testing.T) {
_, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1",
"value": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
}
}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "NotAPanel")
})
t.Run("query kind incompatible with panel rejected", func(t *testing.T) {
// PromQLQuery is not allowed on ListPanel — verify the cross-check
// in Validate still runs after a patch.
_, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p2",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/ListPanel", "spec": {}},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
}]`).Apply(base)
require.Error(t, err)
})
t.Run("removing the only query rejected", func(t *testing.T) {
// Validate requires exactly one query per panel — leaving zero is rejected.
_, err := decode(t, `[{"op": "remove", "path": "/spec/panels/p2/spec/queries/0"}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "panel must have one query")
})
t.Run("two direct queries rejected", func(t *testing.T) {
// Validate requires exactly one query per panel. To display multiple
// data sources in one panel, wrap them in a CompositeQuery (see the
// "replace query with composite" subtest below).
_, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "metrics",
"aggregations": [{"metricName": "signoz_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
}}}},
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "B", "signal": "metrics",
"aggregations": [{"metricName": "signoz_db_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
}}}}
]
}
}
}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "panel must have one query")
})
t.Run("too many tags rejected", func(t *testing.T) {
// Base already has 2 tags; add 9 more to exceed MaxTagsPerDashboard (10).
_, err := decode(t, `[
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "1"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "2"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "3"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "4"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "5"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "6"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "7"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "8"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "9"}}
]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "at most")
})
}