From df7ef3cdb540c024bff5b973ce69a262451b52df Mon Sep 17 00:00:00 2001 From: Naman Verma Date: Thu, 4 Jun 2026 17:29:40 +0530 Subject: [PATCH 1/4] feat: v2 dashboard update, lock, unlock, and patch API (#11481) * chore: follow proper unmarshal json method structure * feat: v2 create dashboard API * fix: only return name of a tag in dashboard response * fix: use existing tag's casing if new tag is a prefix of an existing tag * fix: go lint fix * fix: more dashboard request validations * chore: separate method for validation * fix: module should also validate postable dashboard * test: integration tests for create API * test: integration test fixes * chore: use existing mapper * fix: remove extra spec from builder query marshalling * fix: merge conflicts * feat: v2 dashboard GET API * feat: v2 dashboard update API * fix: add allowed values in err messages * fix: remove extra (un)marshal cycle * fix: return 500 err if spec is nil for composite kind w/ code comment * fix: no need for copying textboxvariablespec * fix: wrap errors * chore: no v2 subpackage * fix: no v2 package and its consequences * fix: no v2 package and its consequences * Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get * fix: merge fixes * fix: query-less panels not allowed * feat: consolidate tag module and tagtypes changes from downstream branches * chore: update api specs * chore: update api specs * fix: allow only 1 query in a panel * test: unit test fixes * feat: method to fetch tags for multiple entries at once * test: fix mock interface in test * feat: move tags to key:value pairs model * feat: entity type column in tags * fix: pass entity type in create many * feat: reserved DSL key validation for tags * feat: new module for tags * chore: merge conflicts error fixing pt 1 * fix: lint fix regarding nil, nil return in test file * chore: change where tag module is instantiated * fix: add back api endpoint * chore: generate api spec * fix: remove soft delete references * chore: embed StorableDashboard into joinedRow in store method * fix: extend bun in joinedRow * fix: compile error fix * fix: remove soft delete references * feat: method to build postable tags from tags * fix: diff error codes for invalid keys and values * fix: correct pk in bun model for tag relations * fix: created and updated by schema * fix: use coretypes.Kind instead of defining entity type * fix: singular table name * chore: remove org ID from tag relation * feat: foreign key on tag id * feat: add SyncTags method that covers creation and linking * fix: remove entity type definition * fix: fix build errors in dashboard module * chore: bump migration number * chore: change entity id to resource id * fix: add org id filter in all list and delete queries * fix: remove user auditable * fix: add ID in tag relation * fix: fix build error * fix: fix build error * chore: bump migration number * fix: add len check on tags keys and values * fix: add regex for tags * chore: remove methods that shouldn't be exposed * fix: use sync tags in create api * feat: functional unique index in sql schema * fix: only ascii in regex * fix: use sync tags method in update * chore: rename create method to createOrGet * chore: use tagtypestest package for mock store * chore: combine functional unique index with unique index * chore: move tag resolution to module * test: add unit tests for new idx type * chore: comment out tags unique index for now * chore: add a todo comment * chore: comment out unique index test * feat: add created at to tag relations * chore: comment out unique index test * chore: bump migration number * chore: remove uploaded grafana flag from metadata * Merge branch 'main' into nv/v2-dashboard-create * chore: revert idx generation to resolve conflicts * fix: use store.RunInTx instead of taking in sqlstore * fix: use binding package to get request * chore: move NewDashboardV2 to NewDashboardV2WithoutTags * chore: rename module to m * fix: add ctx needed in sqlstore * fix: remove sqlstore passage in ee pkg * chore: change dashboardData to dashboardSpec * feat: follow the metadata+spec key structure * feat: follow the metadata+spec key structure in open api spec * feat: v2 dashboard GET API (#11136) * feat: v2 dashboard GET API * Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get * chore: update api specs * fix: remove soft delete references * chore: embed StorableDashboard into joinedRow in store method * fix: fix build error * chore: revert all frontend changes * fix: remove public dashboard from get v2 call * chore: revert all frontend changes * fix: fix build errors post merge conflict resolution * feat: lock, unlock, create public, update public v2 dashboard APIs (#11167) * feat: lock, unlock, create public, update public v2 dashboard APIs * chore: update api specs * fix: use new pattern of checking for admin permission * fix: remove soft delete reference * chore: revert all frontend changes * fix: fix build errors and remove v2 create/update public apis * chore: use v1 methods wherever possible * fix: use update v2 store method * chore: update frontend schema * chore: update frontend schema * chore: generate api specs * chore: generate api specs * feat: patch dashboard api (#11182) * feat: lock, unlock, create public, update public v2 dashboard APIs * feat: delete dashboard v2 API and hard delete cron job * feat: patch dashboard api * chore: update api specs * chore: update api specs * chore: update api specs * chore: remove delete related work * fix: add examples of structs for value param in param description * test: unit test fixes * fix: use new pattern of checking for admin permission * fix: remove soft delete reference * test: key value tags in test * fix: build error in patch module method * fix: build error in Apply method * fix: use sync tags method in update * fix: fix build errors * fix: fix all patch application tests * chore: add more mapper methods * fix: add source for v2 dashboards * chore: incorporate source * chore: incorporate source in api spec * chore: incorporate source * fix: add some required fields * feat: add immutable name in dashboard v2 * feat: add immutable name in dashboard v2 * feat: add immutable name in dashboard v2 api specs * fix: remove unused param in constructor * fix: improve api descriptions * fix: remove unneeded comment * chore: increase MaxTagsPerDashboard to 10 * fix: set display name in unmarshal json * chore: remove integration test for now (will add along with list api) * feat: add validation on dashboard name * test: fix build errors and tests based on name related changes * fix: correct convertor method name * test: add unit tests for type conversions * chore: remove enum def of threshold comparison operator * feat: add flag to generate unique name in backend * chore: generate api specs * chore: make tags required in postable * fix: build error fix * fix: fix build error in test after merge conflict * fix: remove unused store method * fix: remove unused module methods * fix: use v1 store update method * fix: change data to spec in api param description * chore: add back accidentally removed tests * fix: address review comments * chore: generate frontend api spec * chore: use same jsonpatch package as done in zeus * chore: remove JSONPatchDocument and use patchable everywhere * fix: make remove idempotent in patch * chore: separate file for patch types * chore: better error passage * fix: remove extra decodePatch calls * fix: use must new org id * fix: proper error passage * chore: rename updateable to updatable --------- Co-authored-by: Srikanth Chekuri --- docs/api/openapi.yml | 333 ++++++++++ ee/modules/dashboard/impldashboard/module.go | 12 + .../api/generated/services/dashboard/index.ts | 365 +++++++++++ .../api/generated/services/sigNoz.schemas.ts | 81 +++ go.mod | 1 + go.sum | 2 + pkg/apiserver/signozapiserver/dashboard.go | 83 ++- pkg/modules/dashboard/dashboard.go | 14 + pkg/modules/dashboard/impldashboard/store.go | 2 +- .../dashboard/impldashboard/v2_handler.go | 133 ++++ .../dashboard/impldashboard/v2_module.go | 94 +++ pkg/types/dashboardtypes/dashboard.go | 1 + pkg/types/dashboardtypes/perses_dashboard.go | 107 +++- .../dashboardtypes/perses_dashboard_patch.go | 83 +++ .../perses_dashboard_patch_test.go | 569 ++++++++++++++++++ 15 files changed, 1871 insertions(+), 9 deletions(-) create mode 100644 pkg/types/dashboardtypes/perses_dashboard_patch.go create mode 100644 pkg/types/dashboardtypes/perses_dashboard_patch_test.go diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index 33df786a49c..495f9360588 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -2645,6 +2645,30 @@ 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/, /spec/panels//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/ takes a DashboardtypesPanel; /spec/panels//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' @@ -2860,6 +2884,20 @@ 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: @@ -3147,6 +3185,27 @@ components: timePreference: $ref: '#/components/schemas/DashboardtypesTimePreference' type: object + DashboardtypesUpdatableDashboardV2: + 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 DashboardtypesUpdatablePublicDashboard: properties: defaultTimeRange: @@ -12824,6 +12883,12 @@ paths: - data type: object description: Created + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request "401": content: application/json: @@ -12876,6 +12941,12 @@ paths: - data type: object description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request "401": content: application/json: @@ -12888,6 +12959,12 @@ paths: schema: $ref: '#/components/schemas/RenderErrorResponse' description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Not Found "500": content: application/json: @@ -12902,6 +12979,262 @@ 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/DashboardtypesUpdatableDashboardV2' + 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 diff --git a/ee/modules/dashboard/impldashboard/module.go b/ee/modules/dashboard/impldashboard/module.go index 7a7a84bdbd8..26bfc586383 100644 --- a/ee/modules/dashboard/impldashboard/module.go +++ b/ee/modules/dashboard/impldashboard/module.go @@ -221,6 +221,18 @@ 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, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) { + return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable) +} + +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) } diff --git a/frontend/src/api/generated/services/dashboard/index.ts b/frontend/src/api/generated/services/dashboard/index.ts index ef7f5d4764c..c6a73e9c213 100644 --- a/frontend/src/api/generated/services/dashboard/index.ts +++ b/frontend/src/api/generated/services/dashboard/index.ts @@ -21,8 +21,10 @@ import type { CreateDashboardV2201, CreatePublicDashboard201, CreatePublicDashboardPathParameters, + DashboardtypesPatchableDashboardV2DTO, DashboardtypesPostableDashboardV2DTO, DashboardtypesPostablePublicDashboardDTO, + DashboardtypesUpdatableDashboardV2DTO, DashboardtypesUpdatablePublicDashboardDTO, DeletePublicDashboardPathParameters, GetDashboardV2200, @@ -33,7 +35,13 @@ import type { GetPublicDashboardPathParameters, GetPublicDashboardWidgetQueryRange200, GetPublicDashboardWidgetQueryRangePathParameters, + LockDashboardV2PathParameters, + PatchDashboardV2200, + PatchDashboardV2PathParameters, RenderErrorResponseDTO, + UnlockDashboardV2PathParameters, + UpdateDashboardV2200, + UpdateDashboardV2PathParameters, UpdatePublicDashboardPathParameters, } from '../sigNoz.schemas'; @@ -816,3 +824,360 @@ 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 | null, + signal?: AbortSignal, +) => { + return GeneratedAPIInstance({ + url: `/api/v2/dashboards/${id}`, + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + data: dashboardtypesPatchableDashboardV2DTONull, + signal, + }); +}; + +export const getPatchDashboardV2MutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { + pathParams: PatchDashboardV2PathParameters; + data?: BodyType; + }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { + pathParams: PatchDashboardV2PathParameters; + data?: BodyType; + }, + 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>, + { + pathParams: PatchDashboardV2PathParameters; + data?: BodyType; + } + > = (props) => { + const { pathParams, data } = props ?? {}; + + return patchDashboardV2(pathParams, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type PatchDashboardV2MutationResult = NonNullable< + Awaited> +>; +export type PatchDashboardV2MutationBody = + | BodyType + | undefined; +export type PatchDashboardV2MutationError = ErrorType; + +/** + * @summary Patch dashboard (v2) + */ +export const usePatchDashboardV2 = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { + pathParams: PatchDashboardV2PathParameters; + data?: BodyType; + }, + TContext + >; +}): UseMutationResult< + Awaited>, + TError, + { + pathParams: PatchDashboardV2PathParameters; + data?: BodyType; + }, + 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, + dashboardtypesUpdatableDashboardV2DTO?: BodyType, + signal?: AbortSignal, +) => { + return GeneratedAPIInstance({ + url: `/api/v2/dashboards/${id}`, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + data: dashboardtypesUpdatableDashboardV2DTO, + signal, + }); +}; + +export const getUpdateDashboardV2MutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { + pathParams: UpdateDashboardV2PathParameters; + data?: BodyType; + }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { + pathParams: UpdateDashboardV2PathParameters; + data?: BodyType; + }, + 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>, + { + pathParams: UpdateDashboardV2PathParameters; + data?: BodyType; + } + > = (props) => { + const { pathParams, data } = props ?? {}; + + return updateDashboardV2(pathParams, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateDashboardV2MutationResult = NonNullable< + Awaited> +>; +export type UpdateDashboardV2MutationBody = + | BodyType + | undefined; +export type UpdateDashboardV2MutationError = ErrorType; + +/** + * @summary Update dashboard (v2) + */ +export const useUpdateDashboardV2 = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { + pathParams: UpdateDashboardV2PathParameters; + data?: BodyType; + }, + TContext + >; +}): UseMutationResult< + Awaited>, + TError, + { + pathParams: UpdateDashboardV2PathParameters; + data?: BodyType; + }, + 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({ + url: `/api/v2/dashboards/${id}/lock`, + method: 'DELETE', + signal, + }); +}; + +export const getUnlockDashboardV2MutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { pathParams: UnlockDashboardV2PathParameters }, + TContext + >; +}): UseMutationOptions< + Awaited>, + 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>, + { pathParams: UnlockDashboardV2PathParameters } + > = (props) => { + const { pathParams } = props ?? {}; + + return unlockDashboardV2(pathParams); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UnlockDashboardV2MutationResult = NonNullable< + Awaited> +>; + +export type UnlockDashboardV2MutationError = ErrorType; + +/** + * @summary Unlock dashboard (v2) + */ +export const useUnlockDashboardV2 = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { pathParams: UnlockDashboardV2PathParameters }, + TContext + >; +}): UseMutationResult< + Awaited>, + 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({ + url: `/api/v2/dashboards/${id}/lock`, + method: 'PUT', + signal, + }); +}; + +export const getLockDashboardV2MutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { pathParams: LockDashboardV2PathParameters }, + TContext + >; +}): UseMutationOptions< + Awaited>, + 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>, + { pathParams: LockDashboardV2PathParameters } + > = (props) => { + const { pathParams } = props ?? {}; + + return lockDashboardV2(pathParams); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type LockDashboardV2MutationResult = NonNullable< + Awaited> +>; + +export type LockDashboardV2MutationError = ErrorType; + +/** + * @summary Lock dashboard (v2) + */ +export const useLockDashboardV2 = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { pathParams: LockDashboardV2PathParameters }, + TContext + >; +}): UseMutationResult< + Awaited>, + TError, + { pathParams: LockDashboardV2PathParameters }, + TContext +> => { + return useMutation(getLockDashboardV2MutationOptions(options)); +}; diff --git a/frontend/src/api/generated/services/sigNoz.schemas.ts b/frontend/src/api/generated/services/sigNoz.schemas.ts index 6158ba19de2..7fed15fe0d6 100644 --- a/frontend/src/api/generated/services/sigNoz.schemas.ts +++ b/frontend/src/api/generated/services/sigNoz.schemas.ts @@ -4653,6 +4653,32 @@ 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/, /spec/panels//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/ takes a DashboardtypesPanel; /spec/panels//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', @@ -4662,6 +4688,13 @@ export enum DashboardtypesPanelPluginKindDTO { 'signoz/HistogramPanel' = 'signoz/HistogramPanel', 'signoz/ListPanel' = 'signoz/ListPanel', } +/** + * @nullable + */ +export type DashboardtypesPatchableDashboardV2DTO = + | DashboardtypesJSONPatchOperationDTO[] + | null; + export interface DashboardtypesPostableDashboardV2DTO { /** * @type boolean @@ -4705,6 +4738,26 @@ export enum DashboardtypesQueryPluginKindDTO { 'signoz/ClickHouseSQL' = 'signoz/ClickHouseSQL', 'signoz/TraceOperator' = 'signoz/TraceOperator', } +export interface DashboardtypesUpdatableDashboardV2DTO { + /** + * @type string + */ + image?: string; + /** + * @type string + */ + name: string; + /** + * @type string + */ + schemaVersion: string; + spec: DashboardtypesDashboardSpecDTO; + /** + * @type array,null + */ + tags: TagtypesPostableTagDTO[] | null; +} + export interface DashboardtypesUpdatablePublicDashboardDTO { /** * @type string @@ -9476,6 +9529,34 @@ 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 diff --git a/go.mod b/go.mod index 95c17a3d271..4c343a4595c 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ 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 diff --git a/go.sum b/go.sum index f00784446a9..60035b997b5 100644 --- a/go.sum +++ b/go.sum @@ -311,6 +311,8 @@ 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= diff --git a/pkg/apiserver/signozapiserver/dashboard.go b/pkg/apiserver/signozapiserver/dashboard.go index 70b3555135e..7e01ce72609 100644 --- a/pkg/apiserver/signozapiserver/dashboard.go +++ b/pkg/apiserver/signozapiserver/dashboard.go @@ -24,9 +24,10 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error { Response: new(dashboardtypes.GettableDashboardV2), ResponseContentType: "application/json", SuccessStatusCode: http.StatusCreated, - ErrorStatusCodes: []int{}, - Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleEditor), + // TODO: add http.StatusConflict once the dashboard name unique index is added. + ErrorStatusCodes: []int{http.StatusBadRequest}, + Deprecated: false, + SecuritySchemes: newSecuritySchemes(types.RoleEditor), })).Methods(http.MethodPost).GetError(); err != nil { return err } @@ -41,13 +42,87 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error { Response: new(dashboardtypes.GettableDashboardV2), ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK, - ErrorStatusCodes: []int{}, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound}, 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.UpdatableDashboardV2), + 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"}, diff --git a/pkg/modules/dashboard/dashboard.go b/pkg/modules/dashboard/dashboard.go index f98e79f4289..1ddcfaf0a85 100644 --- a/pkg/modules/dashboard/dashboard.go +++ b/pkg/modules/dashboard/dashboard.go @@ -60,6 +60,12 @@ 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, updatable dashboardtypes.UpdatableDashboardV2) (*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 { @@ -89,4 +95,12 @@ 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) } diff --git a/pkg/modules/dashboard/impldashboard/store.go b/pkg/modules/dashboard/impldashboard/store.go index 810327cab05..101e1a9c767 100644 --- a/pkg/modules/dashboard/impldashboard/store.go +++ b/pkg/modules/dashboard/impldashboard/store.go @@ -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. - BunDB(). + BunDBCtx(ctx). NewUpdate(). Model(storableDashboard). WherePK(). diff --git a/pkg/modules/dashboard/impldashboard/v2_handler.go b/pkg/modules/dashboard/impldashboard/v2_handler.go index 5fdfbf33bb8..6a34e270d46 100644 --- a/pkg/modules/dashboard/impldashboard/v2_handler.go +++ b/pkg/modules/dashboard/impldashboard/v2_handler.go @@ -9,6 +9,7 @@ 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" @@ -72,3 +73,135 @@ 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 := valuer.MustNewUUID(claims.OrgID) + + 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, + 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 := valuer.MustNewUUID(claims.OrgID) + + 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.UpdatableDashboardV2{} + 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 := valuer.MustNewUUID(claims.OrgID) + + 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()) +} diff --git a/pkg/modules/dashboard/impldashboard/v2_module.go b/pkg/modules/dashboard/impldashboard/v2_module.go index 9aed581904c..46b6a79bfb1 100644 --- a/pkg/modules/dashboard/impldashboard/v2_module.go +++ b/pkg/modules/dashboard/impldashboard/v2_module.go @@ -55,3 +55,97 @@ 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, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) { + if err := updatable.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, updatable.Tags) + if err != nil { + return err + } + + err = existing.Update(updatable, 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) +} diff --git a/pkg/types/dashboardtypes/dashboard.go b/pkg/types/dashboardtypes/dashboard.go index 935558e50ef..922ed53843f 100644 --- a/pkg/types/dashboardtypes/dashboard.go +++ b/pkg/types/dashboardtypes/dashboard.go @@ -21,6 +21,7 @@ var ( ErrCodeDashboardInvalidWidgetQuery = errors.MustNewCode("dashboard_invalid_widget_query") ErrCodeDashboardInvalidSource = errors.MustNewCode("dashboard_invalid_source") ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable") + ErrCodeDashboardInvalidPatch = errors.MustNewCode("dashboard_invalid_patch") ) type StorableDashboard struct { diff --git a/pkg/types/dashboardtypes/perses_dashboard.go b/pkg/types/dashboardtypes/perses_dashboard.go index d5511bdf068..b4541a8799c 100644 --- a/pkg/types/dashboardtypes/perses_dashboard.go +++ b/pkg/types/dashboardtypes/perses_dashboard.go @@ -62,6 +62,54 @@ 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(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error { + if err := d.CanUpdate(); err != nil { + return err + } + if updatable.Name != d.Name { + return errors.NewInvalidInputf(ErrCodeDashboardImmutable, "name is immutable; cannot change from %q to %q", d.Name, updatable.Name) + } + d.DashboardV2MetadataBase = updatable.DashboardV2MetadataBase + d.Tags = resolvedTags + d.Spec = updatable.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"` @@ -126,7 +174,7 @@ func (p *PostableDashboardV2) Validate() error { if err := p.validateName(); err != nil { return err } - if err := p.validateTags(); err != nil { + if err := validateDashboardTags(p.Tags); err != nil { return err } return p.Spec.Validate() @@ -193,11 +241,11 @@ func generateDashboardName(displayName string) string { return prefix + "-" + string(suffix) } -func (p *PostableDashboardV2) validateTags() error { - if len(p.Tags) > MaxTagsPerDashboard { +func validateDashboardTags(tags []tagtypes.PostableTag) error { + if len(tags) > MaxTagsPerDashboard { return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard) } - for _, tag := range p.Tags { + for _, tag := range tags { if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(strings.TrimSpace(tag.Key)))]; reserved { return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "tag key %q is reserved", tag.Key) } @@ -263,6 +311,57 @@ func (s StorableDashboardV2Data) toStorableDashboardData() (StorableDashboardDat type StorableDashboardV2Metadata = DashboardV2MetadataBase +// ════════════════════════════════════════════════════════════════════════ +// Updatable +// ════════════════════════════════════════════════════════════════════════ + +type UpdatableDashboardV2 struct { + DashboardV2MetadataBase + Name string `json:"name" required:"true"` + Tags []tagtypes.PostableTag `json:"tags" required:"true"` + Spec DashboardSpec `json:"spec" required:"true"` +} + +func (u *UpdatableDashboardV2) UnmarshalJSON(data []byte) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + type alias UpdatableDashboardV2 + var tmp alias + if err := dec.Decode(&tmp); err != nil { + return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error()) + } + *u = UpdatableDashboardV2(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 *UpdatableDashboardV2) 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) toUpdatableDashboardV2() UpdatableDashboardV2 { + return UpdatableDashboardV2{ + DashboardV2MetadataBase: d.DashboardV2MetadataBase, + Name: d.Name, + Tags: tagtypes.NewPostableTagsFromTags(d.Tags), + Spec: d.Spec, + } +} + // ════════════════════════════════════════════════════════════════════════ // Convertors // ════════════════════════════════════════════════════════════════════════ diff --git a/pkg/types/dashboardtypes/perses_dashboard_patch.go b/pkg/types/dashboardtypes/perses_dashboard_patch.go new file mode 100644 index 00000000000..3d454f14ce1 --- /dev/null +++ b/pkg/types/dashboardtypes/perses_dashboard_patch.go @@ -0,0 +1,83 @@ +package dashboardtypes + +import ( + "encoding/json" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/valuer" + jsonpatch "github.com/evanphx/json-patch/v5" + "github.com/swaggest/jsonschema-go" +) + +// PatchableDashboardV2 is an RFC 6902 patch request. +type PatchableDashboardV2 struct { + // Ops shapes the OpenAPI schema; tagged so swaggest reflects it. + Ops []JSONPatchOperation `json:"ops"` + // patch holds the decoded payload, set by UnmarshalJSON. + patch jsonpatch.Patch +} + +// PrepareJSONSchema collapses the struct's object schema into the bare ops array. +func (PatchableDashboardV2) PrepareJSONSchema(s *jsonschema.Schema) error { + // Called on several passes; only the one with built properties carries `ops`. + ops, ok := s.Properties["ops"] + if !ok || ops.TypeObject == nil { + return nil + } + *s = *ops.TypeObject + return nil +} + +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/, /spec/panels//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/ takes a DashboardtypesPanel; /spec/panels//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 { + patch, err := jsonpatch.DecodePatch(data) + if err != nil { + return errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "request body is not a valid RFC 6902 JSON Patch document").WithAdditional(err.Error()) + } + if err := json.Unmarshal(data, &p.Ops); err != nil { + return errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "request body is not a valid RFC 6902 JSON Patch document").WithAdditional(err.Error()) + } + p.patch = patch + return nil +} + +func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdatableDashboardV2, error) { + existingAsUpdatable := existing.toUpdatableDashboardV2() + raw, err := json.Marshal(existingAsUpdatable) + if err != nil { + return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal existing dashboard for patch") + } + patched, err := p.patch.ApplyWithOptions(raw, &jsonpatch.ApplyOptions{AllowMissingPathOnRemove: true, EnsurePathExistsOnAdd: true}) + if err != nil { + return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard") + } + out := &UpdatableDashboardV2{} + if err := json.Unmarshal(patched, out); err != nil { + return nil, err + } + return out, nil +} diff --git a/pkg/types/dashboardtypes/perses_dashboard_patch_test.go b/pkg/types/dashboardtypes/perses_dashboard_patch_test.go new file mode 100644 index 00000000000..41aeeb3c224 --- /dev/null +++ b/pkg/types/dashboardtypes/perses_dashboard_patch_test.go @@ -0,0 +1,569 @@ +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 *UpdatableDashboardV2) 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") + }) +} From d3304af0ed32ed866cc8ab52109d02ba7d6ca103 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:23:10 +0530 Subject: [PATCH 2/4] chore: removed workspace suspended page's text for data retention (#11501) * feat: removed workspace locked and suspended page's text for data retention * chore: reverted changes to workspacelocked file --- .../src/pages/WorkspaceLocked/WorkspaceLocked.tsx | 14 +++++++------- .../WorkspaceSuspended/WorkspaceSuspended.tsx | 11 ----------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx index b4d6bdab459..0a75fd75863 100644 --- a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx @@ -53,20 +53,20 @@ export default function WorkspaceBlocked(): JSX.Element { const { t } = useTranslation(['workspaceLocked']); useEffect((): void => { - logEvent('Workspace Blocked: Screen Viewed', {}); + void logEvent('Workspace Blocked: Screen Viewed', {}); }, []); const handleContactUsClick = (): void => { - logEvent('Workspace Blocked: Contact Us Clicked', {}); + void logEvent('Workspace Blocked: Contact Us Clicked', {}); }; const handleTabClick = (key: string): void => { - logEvent('Workspace Blocked: Screen Tabs Clicked', { tabKey: key }); + void logEvent('Workspace Blocked: Screen Tabs Clicked', { tabKey: key }); }; const handleCollapseChange = (key: string | string[]): void => { const lastKey = Array.isArray(key) ? key.slice(-1)[0] : key; - logEvent('Workspace Blocked: Screen Tab FAQ Item Clicked', { + void logEvent('Workspace Blocked: Screen Tab FAQ Item Clicked', { panelKey: lastKey, }); }; @@ -109,7 +109,7 @@ export default function WorkspaceBlocked(): JSX.Element { ); const handleUpdateCreditCard = useCallback(async () => { - logEvent('Workspace Blocked: User Clicked Update Credit Card', {}); + void logEvent('Workspace Blocked: User Clicked Update Credit Card', {}); updateCreditCard({ url: getBaseUrl(), @@ -117,7 +117,7 @@ export default function WorkspaceBlocked(): JSX.Element { }, [updateCreditCard]); const handleExtendTrial = (): void => { - logEvent('Workspace Blocked: User Clicked Extend Trial', {}); + void logEvent('Workspace Blocked: User Clicked Extend Trial', {}); notifications.info({ message: t('extendTrial'), @@ -133,7 +133,7 @@ export default function WorkspaceBlocked(): JSX.Element { }; const handleViewBilling = (e?: React.MouseEvent): void => { - logEvent('Workspace Blocked: User Clicked View Billing', {}); + void logEvent('Workspace Blocked: User Clicked View Billing', {}); safeNavigate(ROUTES.BILLING, { newTab: !!e && isModifierKeyPressed(e) }); }; diff --git a/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx b/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx index b3b84e1d078..590d0be5c0e 100644 --- a/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx +++ b/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx @@ -6,14 +6,12 @@ import { Typography } from '@signozhq/ui/typography'; import manageCreditCardApi from 'api/v1/portal/create'; import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus'; import ROUTES from 'constants/routes'; -import dayjs from 'dayjs'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; import { useAppContext } from 'providers/App/App'; import APIError from 'types/api/error'; import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive'; import { getBaseUrl } from 'utils/basePath'; -import { getFormattedDateWithMinutes } from 'utils/timeUtils'; import featureGraphicCorrelationUrl from '@/assets/Images/feature-graphic-correlation.svg'; @@ -109,15 +107,6 @@ function WorkspaceSuspended(): JSX.Element { {t('actionDescription')} -
- {t('yourDataIsSafe')}{' '} - - {getFormattedDateWithMinutes( - dayjs(activeLicense?.event_queue?.scheduled_at).unix() || - Date.now(), - )} - {' '} - {t('actNow')}
From 959e32b6f331bd630d6f8ff7ff383238d5595f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vinicius=20Louren=C3=A7o?= <12551007+H4ad@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:00:10 -0300 Subject: [PATCH 3/4] chore(codeowners): move openapi schema generation ownership to backend (#11585) --- .github/CODEOWNERS | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4b7a66ee207..80eb0b41d6d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -188,3 +188,7 @@ go.mod @therealpandey /frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend /frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend /frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend + +## OpenAPI Schema - Generated +/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv +/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv From 4cf8125372d0069717784dd3ece9faa103856fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vinicius=20Louren=C3=A7o?= <12551007+H4ad@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:30:44 -0300 Subject: [PATCH 4/4] refactor(sentry-config): disable tracing & ensure correct environment (#11552) * refactor(sentry-config): disable tracing & ensure correct environment * refactor(sentry-config): derive environment from build-time env var Replace the runtime hostname-based getCurrentEnvironment() with a build-time process.env.ENVIRONMENT value. The environment is injected once at build time via VITE_ENVIRONMENT, wired through the vite define block, and consumed directly by Sentry.init. Staging builds bake in 'staging' and enterprise builds bake in 'production'. * refactor(sentry-config): keep browserTracingIntegration for transaction tag Tracing stays disabled (tracesSampleRate: 0), but the integration is retained so the transaction tag remains available for routing. Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055 * feat(sentry-config): set Sentry release from VITE_VERSION Set release in Sentry.init from process.env.VERSION (VITE_VERSION, default 'dev') and pin the @sentry/vite-plugin upload release to the same value so sourcemaps resolve. Plumb VITE_VERSION through build-enterprise & build-staging (make info-version) and gor-signoz (tag). * fix(sentry-config): align Sentry.init indentation and set VITE_ENVIRONMENT for gor-signoz * fix(sentry-config): require VITE_VERSION for sourcemap upload Refuse to register the Sentry vite plugin without an explicit VITE_VERSION, and drop the 'dev' fallback for both the plugin release name and process.env.VERSION so a sourcemap upload can never happen without a matching release. --------- Co-authored-by: grandwizard28 --- .github/workflows/build-enterprise.yaml | 2 ++ .github/workflows/build-staging.yaml | 2 ++ .github/workflows/gor-signoz.yaml | 2 ++ frontend/src/AppRoutes/index.tsx | 11 +++++------ frontend/src/vite-env.d.ts | 2 ++ frontend/vite.config.ts | 11 +++++++++++ 6 files changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-enterprise.yaml b/.github/workflows/build-enterprise.yaml index ee362806f9c..00da44c45e0 100644 --- a/.github/workflows/build-enterprise.yaml +++ b/.github/workflows/build-enterprise.yaml @@ -69,6 +69,8 @@ jobs: echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> frontend/.env echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> frontend/.env + echo 'VITE_ENVIRONMENT="production"' >> frontend/.env + echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env - name: cache-dotenv uses: actions/cache@v4 with: diff --git a/.github/workflows/build-staging.yaml b/.github/workflows/build-staging.yaml index d6f3a71e417..cad4489d4fc 100644 --- a/.github/workflows/build-staging.yaml +++ b/.github/workflows/build-staging.yaml @@ -70,6 +70,8 @@ jobs: echo 'VITE_APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.NP_PYLON_IDENTITY_SECRET }}"' >> frontend/.env echo 'VITE_DOCS_BASE_URL="https://staging.signoz.io"' >> frontend/.env + echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env + echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env - name: cache-dotenv uses: actions/cache@v4 with: diff --git a/.github/workflows/gor-signoz.yaml b/.github/workflows/gor-signoz.yaml index 34c59a964dc..2555894cd25 100644 --- a/.github/workflows/gor-signoz.yaml +++ b/.github/workflows/gor-signoz.yaml @@ -35,6 +35,8 @@ jobs: echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> .env echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> .env + echo 'VITE_ENVIRONMENT="production"' >> .env + echo 'VITE_VERSION="${{ github.ref_name }}"' >> .env - name: node-setup uses: actions/setup-node@v5 with: diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index 62a28ae1d81..d3322079e4a 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -351,19 +351,18 @@ function App(): JSX.Element { Sentry.init({ dsn: process.env.SENTRY_DSN, tunnel: process.env.TUNNEL_URL, - environment: 'production', + environment: process.env.ENVIRONMENT, + release: process.env.VERSION, integrations: [ + // Kept for the `transaction` tag used in routing, even though + // tracing is disabled. Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055 Sentry.browserTracingIntegration(), Sentry.replayIntegration({ maskAllText: false, blockAllMedia: false, }), ], - // Performance Monitoring - tracesSampleRate: 1.0, // Capture 100% of the transactions - // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled - tracePropagationTargets: [], - // Session Replay + tracesSampleRate: 0, // Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055 replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. beforeSend(event) { diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 25f2e72292b..43316dd0d0c 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -24,6 +24,8 @@ interface ImportMetaEnv { readonly VITE_TUNNEL_URL: string; readonly VITE_TUNNEL_DOMAIN: string; readonly VITE_DOCS_BASE_URL: string; + readonly VITE_ENVIRONMENT: string; + readonly VITE_VERSION: string; } interface ImportMeta { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index eb9132da102..f866a344bee 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -82,11 +82,20 @@ export default defineConfig(({ mode }): UserConfig => { ]; if (env.VITE_SENTRY_AUTH_TOKEN) { + // Refuse to upload sourcemaps without an explicit version. + if (!env.VITE_VERSION) { + throw new Error( + 'VITE_VERSION must be set to upload sourcemaps to Sentry; refusing to upload without a matching release.', + ); + } plugins.push( sentryVitePlugin({ authToken: env.VITE_SENTRY_AUTH_TOKEN, org: env.VITE_SENTRY_ORG, project: env.VITE_SENTRY_PROJECT_ID, + // Pin the sourcemap-upload release to the same value injected as + // process.env.VERSION so uploaded sourcemaps resolve. Ref: platform-pod#2393 + release: { name: env.VITE_VERSION }, }), ); } @@ -155,6 +164,8 @@ export default defineConfig(({ mode }): UserConfig => { 'process.env.TUNNEL_URL': JSON.stringify(env.VITE_TUNNEL_URL), 'process.env.TUNNEL_DOMAIN': JSON.stringify(env.VITE_TUNNEL_DOMAIN), 'process.env.DOCS_BASE_URL': JSON.stringify(env.VITE_DOCS_BASE_URL), + 'process.env.ENVIRONMENT': JSON.stringify(env.VITE_ENVIRONMENT), + 'process.env.VERSION': JSON.stringify(env.VITE_VERSION), }, // In production, use relative paths so assets work with any base path injected by the backend. // In dev, use the configured base path for proper HMR and routing.