Compare commits

..

21 Commits

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

View File

@@ -618,6 +618,13 @@ components:
provider:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesPatchableRole:
properties:
description:
type: string
required:
- description
type: object
AuthtypesPostableAuthDomain:
properties:
config:
@@ -696,36 +703,6 @@ components:
useRoleAttribute:
type: boolean
type: object
AuthtypesRoleWithTransactionGroups:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
transactionGroups:
items:
$ref: '#/components/schemas/AuthtypesTransactionGroup'
type: array
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- type
- orgId
- transactionGroups
type: object
AuthtypesSamlConfig:
properties:
attributeMapping:
@@ -759,37 +736,11 @@ components:
- relation
- object
type: object
AuthtypesTransactionGroup:
properties:
objectGroup:
$ref: '#/components/schemas/CoretypesObjectGroup'
relation:
$ref: '#/components/schemas/AuthtypesRelation'
required:
- relation
- objectGroup
type: object
AuthtypesUpdatableAuthDomain:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
type: object
AuthtypesUpdatableRole:
properties:
description:
type: string
required:
- description
type: object
AuthtypesUpdatableTransactionGroups:
properties:
transactionGroups:
items:
$ref: '#/components/schemas/AuthtypesTransactionGroup'
type: array
required:
- transactionGroups
type: object
AuthtypesUserRole:
properties:
createdAt:
@@ -2486,17 +2437,6 @@ components:
url:
type: string
type: object
DashboardTextVariableSpec:
properties:
constant:
type: boolean
display:
$ref: '#/components/schemas/VariableDisplay'
name:
type: string
value:
type: string
type: object
DashboardtypesAxes:
properties:
isLogScale:
@@ -2861,9 +2801,15 @@ components:
type: string
nullable: true
type: object
mode:
$ref: '#/components/schemas/DashboardtypesLegendMode'
position:
$ref: '#/components/schemas/DashboardtypesLegendPosition'
type: object
DashboardtypesLegendMode:
enum:
- list
type: string
DashboardtypesLegendPosition:
enum:
- bottom
@@ -2914,15 +2860,25 @@ components:
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
minLength: 1
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesVariablePlugin'
sort:
nullable: true
type: string
$ref: '#/components/schemas/DashboardtypesListVariableSpecSort'
required:
- display
- name
type: object
DashboardtypesListVariableSpecSort:
enum:
- none
- alphabetical-asc
- alphabetical-desc
- numerical-asc
- numerical-desc
- alphabetical-ci-asc
- alphabetical-ci-desc
type: string
DashboardtypesListableDashboardForUserV2:
properties:
dashboards:
@@ -3432,8 +3388,13 @@ components:
DashboardtypesSpanGaps:
properties:
fillLessThan:
description: The maximum gap size to connect when fillOnlyBelow is true.
Gaps larger than this duration are left disconnected.
type: string
fillOnlyBelow:
description: Controls whether lines connect across null values. When false
(default), all gaps are connected. When true, only gaps smaller than fillLessThan
are connected.
type: boolean
type: object
DashboardtypesStorableDashboardData:
@@ -3481,6 +3442,20 @@ components:
- color
- columnName
type: object
DashboardtypesTextVariableSpec:
properties:
constant:
type: boolean
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
minLength: 1
type: string
value:
type: string
required:
- name
type: object
DashboardtypesThresholdFormat:
enum:
- text
@@ -3500,7 +3475,6 @@ components:
required:
- value
- color
- label
type: object
DashboardtypesTimePreference:
enum:
@@ -3585,23 +3559,11 @@ components:
discriminator:
mapping:
ListVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
type: object
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
properties:
kind:
enum:
- TextVariable
type: string
spec:
$ref: '#/components/schemas/DashboardTextVariableSpec'
required:
- kind
- spec
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
type: object
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec:
properties:
@@ -3615,6 +3577,18 @@ components:
- kind
- spec
type: object
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec:
properties:
kind:
enum:
- TextVariable
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTextVariableSpec'
required:
- kind
- spec
type: object
DashboardtypesVariablePlugin:
discriminator:
mapping:
@@ -7700,15 +7674,6 @@ components:
type: object
VariableDefaultValue:
type: object
VariableDisplay:
properties:
description:
type: string
hidden:
type: boolean
name:
type: string
type: object
ZeustypesGettableHost:
properties:
hosts:
@@ -11107,7 +11072,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
$ref: '#/components/schemas/AuthtypesRole'
status:
type: string
required:
@@ -11141,10 +11106,10 @@ paths:
summary: Get role
tags:
- role
put:
patch:
deprecated: false
description: This endpoint updates a role
operationId: UpdateRole
description: This endpoint patches a role
operationId: PatchRole
parameters:
- in: path
name: id
@@ -11155,7 +11120,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdatableRole'
$ref: '#/components/schemas/AuthtypesPatchableRole'
responses:
"204":
description: No Content
@@ -11200,7 +11165,7 @@ paths:
- role:update
- tokenizer:
- role:update
summary: Update role
summary: Patch role
tags:
- role
/api/v1/roles/{id}/relations/{relation}/objects:
@@ -11282,7 +11247,7 @@ paths:
tags:
- role
patch:
deprecated: true
deprecated: false
description: Patches the objects connected to the specified role via a given
relation type
operationId: PatchObjects
@@ -11355,76 +11320,6 @@ paths:
summary: Patch objects for a role by relation
tags:
- role
/api/v1/roles/{id}/transactions:
put:
deprecated: false
description: This endpoint reconciles a role's permissions to exactly the given
transaction groups
operationId: UpdateRoleTransactions
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdatableTransactionGroups'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:update
- tokenizer:
- role:update
summary: Update role transactions
tags:
- role
/api/v1/route_policies:
get:
deprecated: false

View File

@@ -213,30 +213,6 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
return role, nil
}
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
role, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
if err != nil {
return nil, err
}
transactionGroups, err := authtypes.NewTransactionGroupsFromTuples(tuples)
if err != nil {
return nil, err
}
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
@@ -271,36 +247,7 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
return objects, nil
}
func (provider *provider) UpdateTransactions(ctx context.Context, orgID valuer.UUID, id valuer.UUID, transactionGroups []*authtypes.TransactionGroup) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
role, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return err
}
if err := role.ErrIfManaged(); err != nil {
return err
}
desiredTuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, transactionGroups)
if err != nil {
return err
}
currentTuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
if err != nil {
return err
}
additions, deletions := diffTuples(currentTuples, desiredTuples)
return provider.chunkedTuplesWrite(ctx, additions, deletions)
}
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -414,7 +361,7 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
return tuples
}
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
func (provider *provider) deleteTuples(ctx context.Context, roleName string, orgID valuer.UUID) error {
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
@@ -424,76 +371,26 @@ func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName str
Object: objectType.StringValue() + ":",
})
if err != nil {
return nil, err
return err
}
tuples = append(tuples, typeTuples...)
}
return tuples, nil
}
func (provider *provider) chunkedTuplesWrite(ctx context.Context, additions, deletions []*openfgav1.TupleKey) error {
maxTuplesPerWrite := provider.config.OpenFGA.MaxTuplesPerWrite
for idx := 0; idx < len(additions); idx += maxTuplesPerWrite {
end := idx + maxTuplesPerWrite
if end > len(additions) {
end = len(additions)
}
if err := provider.Write(ctx, additions[idx:end], nil); err != nil {
return err
}
}
for idx := 0; idx < len(deletions); idx += maxTuplesPerWrite {
end := idx + maxTuplesPerWrite
if end > len(deletions) {
end = len(deletions)
}
if err := provider.Write(ctx, nil, deletions[idx:end]); err != nil {
return err
}
}
return nil
}
func diffTuples(current, desired []*openfgav1.TupleKey) (additions, deletions []*openfgav1.TupleKey) {
key := func(tuple *openfgav1.TupleKey) string {
return tuple.GetUser() + "|" + tuple.GetRelation() + "|" + tuple.GetObject()
}
currentKeys := make(map[string]struct{}, len(current))
for _, tuple := range current {
currentKeys[key(tuple)] = struct{}{}
}
desiredKeys := make(map[string]struct{}, len(desired))
for _, tuple := range desired {
desiredKeys[key(tuple)] = struct{}{}
if _, ok := currentKeys[key(tuple)]; !ok {
additions = append(additions, tuple)
}
}
for _, tuple := range current {
if _, ok := desiredKeys[key(tuple)]; !ok {
deletions = append(deletions, tuple)
}
}
return additions, deletions
}
func (provider *provider) deleteTuples(ctx context.Context, roleName string, orgID valuer.UUID) error {
tuples, err := provider.readAllTuplesForRole(ctx, roleName, orgID)
if err != nil {
return err
}
if len(tuples) == 0 {
return nil
}
return provider.chunkedTuplesWrite(ctx, nil, tuples)
for idx := 0; idx < len(tuples); idx += provider.config.OpenFGA.MaxTuplesPerWrite {
end := idx + provider.config.OpenFGA.MaxTuplesPerWrite
if end > len(tuples) {
end = len(tuples)
}
err := provider.Write(ctx, nil, tuples[idx:end])
if err != nil {
return err
}
}
return nil
}

View File

@@ -18,9 +18,8 @@ import type {
} from 'react-query';
import type {
AuthtypesPatchableRoleDTO,
AuthtypesPostableRoleDTO,
AuthtypesUpdatableRoleDTO,
AuthtypesUpdatableTransactionGroupsDTO,
CoretypesPatchableObjectsDTO,
CreateRole201,
DeleteRolePathParameters,
@@ -30,9 +29,8 @@ import type {
GetRolePathParameters,
ListRoles200,
PatchObjectsPathParameters,
PatchRolePathParameters,
RenderErrorResponseDTO,
UpdateRolePathParameters,
UpdateRoleTransactionsPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -366,46 +364,46 @@ export const invalidateGetRole = async (
};
/**
* This endpoint updates a role
* @summary Update role
* This endpoint patches a role
* @summary Patch role
*/
export const updateRole = (
{ id }: UpdateRolePathParameters,
authtypesUpdatableRoleDTO?: BodyType<AuthtypesUpdatableRoleDTO>,
export const patchRole = (
{ id }: PatchRolePathParameters,
authtypesPatchableRoleDTO?: BodyType<AuthtypesPatchableRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}`,
method: 'PUT',
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdatableRoleDTO,
data: authtypesPatchableRoleDTO,
signal,
});
};
export const getUpdateRoleMutationOptions = <
export const getPatchRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
> => {
const mutationKey = ['updateRole'];
const mutationKey = ['patchRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
@@ -415,54 +413,54 @@ export const getUpdateRoleMutationOptions = <
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateRole>>,
Awaited<ReturnType<typeof patchRole>>,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateRole(pathParams, data);
return patchRole(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRole>>
export type PatchRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof patchRole>>
>;
export type UpdateRoleMutationBody =
| BodyType<AuthtypesUpdatableRoleDTO>
export type PatchRoleMutationBody =
| BodyType<AuthtypesPatchableRoleDTO>
| undefined;
export type UpdateRoleMutationError = ErrorType<RenderErrorResponseDTO>;
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update role
* @summary Patch role
*/
export const useUpdateRole = <
export const usePatchRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRole>>,
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
> => {
return useMutation(getUpdateRoleMutationOptions(options));
return useMutation(getPatchRoleMutationOptions(options));
};
/**
* Gets all objects connected to the specified role via a given relation type
@@ -567,7 +565,6 @@ export const invalidateGetObjects = async (
/**
* Patches the objects connected to the specified role via a given relation type
* @deprecated
* @summary Patch objects for a role by relation
*/
export const patchObjects = (
@@ -639,7 +636,6 @@ export type PatchObjectsMutationBody =
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch objects for a role by relation
*/
export const usePatchObjects = <
@@ -666,103 +662,3 @@ export const usePatchObjects = <
> => {
return useMutation(getPatchObjectsMutationOptions(options));
};
/**
* This endpoint reconciles a role's permissions to exactly the given transaction groups
* @summary Update role transactions
*/
export const updateRoleTransactions = (
{ id }: UpdateRoleTransactionsPathParameters,
authtypesUpdatableTransactionGroupsDTO?: BodyType<AuthtypesUpdatableTransactionGroupsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}/transactions`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdatableTransactionGroupsDTO,
signal,
});
};
export const getUpdateRoleTransactionsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRoleTransactions>>,
TError,
{
pathParams: UpdateRoleTransactionsPathParameters;
data?: BodyType<AuthtypesUpdatableTransactionGroupsDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRoleTransactions>>,
TError,
{
pathParams: UpdateRoleTransactionsPathParameters;
data?: BodyType<AuthtypesUpdatableTransactionGroupsDTO>;
},
TContext
> => {
const mutationKey = ['updateRoleTransactions'];
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 updateRoleTransactions>>,
{
pathParams: UpdateRoleTransactionsPathParameters;
data?: BodyType<AuthtypesUpdatableTransactionGroupsDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateRoleTransactions(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRoleTransactionsMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRoleTransactions>>
>;
export type UpdateRoleTransactionsMutationBody =
| BodyType<AuthtypesUpdatableTransactionGroupsDTO>
| undefined;
export type UpdateRoleTransactionsMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update role transactions
*/
export const useUpdateRoleTransactions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRoleTransactions>>,
TError,
{
pathParams: UpdateRoleTransactionsPathParameters;
data?: BodyType<AuthtypesUpdatableTransactionGroupsDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRoleTransactions>>,
TError,
{
pathParams: UpdateRoleTransactionsPathParameters;
data?: BodyType<AuthtypesUpdatableTransactionGroupsDTO>;
},
TContext
> => {
return useMutation(getUpdateRoleTransactionsMutationOptions(options));
};

View File

@@ -2194,6 +2194,13 @@ export interface AuthtypesOrgSessionContextDTO {
warning?: ErrorsJSONDTO;
}
export interface AuthtypesPatchableRoleDTO {
/**
* @type string
*/
description: string;
}
export interface AuthtypesPostableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
/**
@@ -2268,56 +2275,6 @@ export interface AuthtypesRoleDTO {
updatedAt?: string;
}
export interface CoretypesObjectGroupDTO {
resource: CoretypesResourceRefDTO;
/**
* @type array
*/
selectors: string[];
}
export interface AuthtypesTransactionGroupDTO {
objectGroup: CoretypesObjectGroupDTO;
relation: AuthtypesRelationDTO;
}
export interface AuthtypesRoleWithTransactionGroupsDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type array
*/
transactionGroups: AuthtypesTransactionGroupDTO[];
/**
* @type string
*/
type: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface AuthtypesSessionContextDTO {
/**
* @type boolean
@@ -2338,20 +2295,6 @@ export interface AuthtypesUpdatableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
}
export interface AuthtypesUpdatableRoleDTO {
/**
* @type string
*/
description: string;
}
export interface AuthtypesUpdatableTransactionGroupsDTO {
/**
* @type array
*/
transactionGroups: AuthtypesTransactionGroupDTO[];
}
export interface AuthtypesUserRoleDTO {
/**
* @type string
@@ -3122,6 +3065,14 @@ export interface CommonJSONRefDTO {
$ref?: string;
}
export interface CoretypesObjectGroupDTO {
resource: CoretypesResourceRefDTO;
/**
* @type array
*/
selectors: string[];
}
export interface CoretypesPatchableObjectsDTO {
/**
* @type array,null
@@ -3203,37 +3154,6 @@ export interface DashboardLinkDTO {
url?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type boolean
*/
hidden?: boolean;
/**
* @type string
*/
name?: string;
}
export interface DashboardTextVariableSpecDTO {
/**
* @type boolean
*/
constant?: boolean;
display?: VariableDisplayDTO;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
value?: string;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
@@ -3265,6 +3185,9 @@ export interface DashboardtypesPanelFormattingDTO {
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
@@ -3284,6 +3207,7 @@ export interface DashboardtypesLegendDTO {
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
@@ -3295,7 +3219,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
label: string;
label?: string;
/**
* @type string
*/
@@ -3960,10 +3884,12 @@ export enum DashboardtypesLineStyleDTO {
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
@@ -4595,6 +4521,15 @@ export type DashboardtypesVariablePluginDTO =
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTO
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTO;
export enum DashboardtypesListVariableSpecSortDTO {
none = 'none',
'alphabetical-asc' = 'alphabetical-asc',
'alphabetical-desc' = 'alphabetical-desc',
'numerical-asc' = 'numerical-asc',
'numerical-desc' = 'numerical-desc',
'alphabetical-ci-asc' = 'alphabetical-ci-asc',
'alphabetical-ci-desc' = 'alphabetical-ci-desc',
}
export interface DashboardtypesListVariableSpecDTO {
/**
* @type boolean
@@ -4613,16 +4548,14 @@ export interface DashboardtypesListVariableSpecDTO {
*/
customAllValue?: string;
defaultValue?: VariableDefaultValueDTO;
display: DashboardtypesDisplayDTO;
display?: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name?: string;
name: string;
plugin?: DashboardtypesVariablePluginDTO;
/**
* @type string,null
*/
sort?: string | null;
sort?: DashboardtypesListVariableSpecSortDTO;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO {
@@ -4634,21 +4567,38 @@ export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDash
spec: DashboardtypesListVariableSpecDTO;
}
export enum DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind {
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind {
TextVariable = 'TextVariable',
}
export interface DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO {
export interface DashboardtypesTextVariableSpecDTO {
/**
* @type boolean
*/
constant?: boolean;
display?: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name: string;
/**
* @type string
*/
value?: string;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO {
/**
* @enum TextVariable
* @type string
*/
kind: DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind;
spec: DashboardTextVariableSpecDTO;
kind: DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind;
spec: DashboardtypesTextVariableSpecDTO;
}
export type DashboardtypesVariableDTO =
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO
| DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO;
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO;
export interface DashboardtypesDashboardSpecDTO {
/**
@@ -9608,14 +9558,14 @@ export type GetRolePathParameters = {
id: string;
};
export type GetRole200 = {
data: AuthtypesRoleWithTransactionGroupsDTO;
data: AuthtypesRoleDTO;
/**
* @type string
*/
status: string;
};
export type UpdateRolePathParameters = {
export type PatchRolePathParameters = {
id: string;
};
export type GetObjectsPathParameters = {
@@ -9637,9 +9587,6 @@ export type PatchObjectsPathParameters = {
id: string;
relation: string;
};
export type UpdateRoleTransactionsPathParameters = {
id: string;
};
export type GetAllRoutePolicies200 = {
/**
* @type array

View File

@@ -10,7 +10,7 @@ import {
invalidateGetRole,
invalidateListRoles,
useCreateRole,
useUpdateRole,
usePatchRole,
} from 'api/generated/services/role';
import {
AuthtypesPostableRoleDTO,
@@ -98,7 +98,7 @@ function CreateRoleModal({
},
});
const { mutate: patchRole, isLoading: isPatching } = useUpdateRole({
const { mutate: patchRole, isLoading: isPatching } = usePatchRole({
mutation: {
onSuccess: () => handleSuccess('Role updated successfully'),
onError: handleError,

View File

@@ -2,15 +2,16 @@ import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { DashboardtypesListVariableSpecSortDTO as VariableSortDTO } from 'api/generated/services/sigNoz.schemas';
import Editor from 'components/Editor';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { VariableSort } from '../variableModel';
import { sortDirectionOf } from '../variableModel';
import styles from './VariableForm.module.scss';
interface QueryVariableFieldsProps {
queryValue: string;
sort: VariableSort;
sort: VariableSortDTO;
onChange: (queryValue: string) => void;
onPreview: (values: (string | number)[]) => void;
onError: (message: string | null) => void;
@@ -36,7 +37,10 @@ function QueryVariableFields({
});
if (res.statusCode === 200 && res.payload) {
onPreview(
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
sortValues(res.payload.variableValues ?? [], sortDirectionOf(sort)) as (
| string
| number
)[],
);
} else {
onError(res.error || 'Failed to run query');

View File

@@ -12,10 +12,12 @@ import { Collapse, Input as AntdInput, Select } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { DashboardtypesListVariableSpecSortDTO as VariableSortDTO } from 'api/generated/services/sigNoz.schemas';
import {
sortDirectionOf,
VARIABLE_SORTS,
type VariableFormModel,
type VariableSort,
type VariableType,
} from '../variableModel';
import DynamicVariableFields from './DynamicVariableFields';
@@ -23,10 +25,16 @@ import QueryVariableFields from './QueryVariableFields';
import VariableTypeSelector from './VariableTypeSelector';
import styles from './VariableForm.module.scss';
const SORT_LABEL: Record<VariableSort, string> = {
DISABLED: 'Disabled',
ASC: 'Ascending',
DESC: 'Descending',
const SORT_LABEL: Record<VariableSortDTO, string> = {
[VariableSortDTO.none]: 'Disabled',
[VariableSortDTO['alphabetical-asc']]: 'Alphabetical (asc)',
[VariableSortDTO['alphabetical-desc']]: 'Alphabetical (desc)',
[VariableSortDTO['numerical-asc']]: 'Numerical (asc)',
[VariableSortDTO['numerical-desc']]: 'Numerical (desc)',
[VariableSortDTO['alphabetical-ci-asc']]:
'Alphabetical, case-insensitive (asc)',
[VariableSortDTO['alphabetical-ci-desc']]:
'Alphabetical, case-insensitive (desc)',
};
function getNameError(name: string, existingNames: string[]): string | null {
@@ -91,7 +99,10 @@ function VariableForm({
const onCustomChange = (value: string): void => {
set({ customValue: value });
setPreviewValues(
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
sortValues(commaValuesParser(value), sortDirectionOf(model.sort)) as (
| string
| number
)[],
);
};
@@ -259,7 +270,7 @@ function VariableForm({
label: SORT_LABEL[sort],
value: sort,
}))}
onChange={(value): void => set({ sort: value as VariableSort })}
onChange={(value): void => set({ sort: value as VariableSortDTO })}
testId="variable-sort-select"
/>
</div>

View File

@@ -1,16 +1,17 @@
import {
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
DashboardtypesListVariableSpecSortDTO as VariableSortDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
DashboardtypesVariablePluginDTO,
DashboardTextVariableSpecDTO,
DashboardtypesTextVariableSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
@@ -18,7 +19,6 @@ import {
PLUGIN_KIND,
type TelemetrySignal,
type VariableFormModel,
type VariableSort,
} from './variableModel';
/** DTO envelope → flat form model (for display / editing). */
@@ -35,7 +35,7 @@ export function dtoToFormModel(
// Text variable — a distinct envelope (no list plugin).
if (dto.kind === TextEnvelopeKind.TextVariable) {
const spec = dto.spec as DashboardTextVariableSpecDTO;
const spec = dto.spec as DashboardtypesTextVariableSpecDTO;
return {
...common,
type: 'TEXT',
@@ -50,7 +50,7 @@ export function dtoToFormModel(
...common,
multiSelect: spec.allowMultiple ?? false,
showAllOption: spec.allowAllValue ?? false,
sort: (spec.sort as VariableSort) ?? 'DISABLED',
sort: spec.sort ?? VariableSortDTO.none,
defaultValue: spec.defaultValue,
};
const plugin = spec.plugin;

View File

@@ -1,4 +1,6 @@
import { DashboardtypesListVariableSpecSortDTO as VariableSortDTO } from 'api/generated/services/sigNoz.schemas';
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
import type { TSortVariableValuesType } from 'types/api/dashboard/getAll';
/**
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
@@ -8,8 +10,6 @@ import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.sche
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
export type VariableSort = 'DISABLED' | 'ASC' | 'DESC';
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
/** Wire `kind` discriminators (string values of the generated enums). */
@@ -24,7 +24,20 @@ export const PLUGIN_KIND = {
DYNAMIC: 'signoz/DynamicVariable',
} as const;
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
export const VARIABLE_SORTS: VariableSortDTO[] = Object.values(VariableSortDTO);
/** Direction the preview sorter should apply for a given wire sort value. */
export function sortDirectionOf(
sort: VariableSortDTO,
): TSortVariableValuesType {
if (sort.endsWith('-asc')) {
return 'ASC';
}
if (sort.endsWith('-desc')) {
return 'DESC';
}
return 'DISABLED';
}
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
'traces',
@@ -42,7 +55,7 @@ export interface VariableFormModel {
// List-variable common fields (Query / Custom / Dynamic).
multiSelect: boolean;
showAllOption: boolean;
sort: VariableSort;
sort: VariableSortDTO;
// Type-specific.
queryValue: string; // QUERY
@@ -67,7 +80,7 @@ export function emptyVariableFormModel(): VariableFormModel {
type: 'QUERY',
multiSelect: false,
showAllOption: false,
sort: 'DISABLED',
sort: VariableSortDTO.none,
queryValue: '',
customValue: '',
textValue: '',

View File

@@ -73,7 +73,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.RoleWithTransactionGroups),
Response: new(authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -91,60 +91,6 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/roles/{id}/transactions", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.UpdateTransactions, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateRoleTransactions",
Tags: []string{"role"},
Summary: "Update role transactions",
Description: "This endpoint reconciles a role's permissions to exactly the given transaction groups",
Request: new(authtypes.UpdatableTransactionGroups),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.GetObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
@@ -173,13 +119,13 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Update, authtypes.SigNozAdminRoleName),
provider.authzMiddleware.CheckResources(provider.authzHandler.Patch, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateRole",
ID: "PatchRole",
Tags: []string{"role"},
Summary: "Update role",
Description: "This endpoint updates a role",
Request: new(authtypes.UpdatableRole),
Summary: "Patch role",
Description: "This endpoint patches a role",
Request: new(authtypes.PatchableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
@@ -195,7 +141,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
@@ -212,7 +158,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: true,
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
@@ -226,5 +172,32 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -30,21 +30,30 @@ type AuthZ interface {
// Write accepts the insertion tuples and the deletion tuples.
Write(context.Context, []*openfgav1.TupleKey, []*openfgav1.TupleKey) error
// Creates the role (metadata only; transactions are set via PutTransactions).
Create(context.Context, valuer.UUID, *authtypes.Role) error
// Lists the selectors for objects assigned to subject (s) with relation (r) on resource (s)
ListObjects(context.Context, string, authtypes.Relation, coretypes.Type) ([]*coretypes.Object, error)
// PutTransactions reconciles a role's authz tuples to exactly the given transaction groups.
UpdateTransactions(context.Context, valuer.UUID, valuer.UUID, []*authtypes.TransactionGroup) error
// Creates the role.
Create(context.Context, valuer.UUID, *authtypes.Role) error
// Gets the role if it exists or creates one.
GetOrCreate(context.Context, valuer.UUID, *authtypes.Role) (*authtypes.Role, error)
// Gets the objects associated with the given role and relation.
GetObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation) ([]*coretypes.Object, error)
// Patches the role.
Patch(context.Context, valuer.UUID, *authtypes.Role) error
// Patches the objects in authorization server associated with the given role and relation
PatchObjects(context.Context, valuer.UUID, string, authtypes.Relation, []*coretypes.Object, []*coretypes.Object) error
// Deletes the role and tuples in authorization server.
Delete(context.Context, valuer.UUID, valuer.UUID) error
// Gets the role
Get(context.Context, valuer.UUID, valuer.UUID) (*authtypes.Role, error)
// Gets the role with transaction groups
GetWithTransactionGroups(context.Context, valuer.UUID, valuer.UUID) (*authtypes.RoleWithTransactionGroups, error)
// Gets the role by org_id and name
GetByOrgIDAndName(context.Context, valuer.UUID, string) (*authtypes.Role, error)
@@ -57,9 +66,6 @@ type AuthZ interface {
// Lists all the roles for the organization filtered by ids
ListByOrgIDAndIDs(context.Context, valuer.UUID, []valuer.UUID) ([]*authtypes.Role, error)
// Deletes the role and tuples in authorization server.
Delete(context.Context, valuer.UUID, valuer.UUID) error
// Grants a role to the subject based on role name.
Grant(context.Context, valuer.UUID, []string, string) error
@@ -77,18 +83,6 @@ type AuthZ interface {
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error)
// Lists the selectors for objects assigned to subject (s) with relation (r) on resource (s)
ListObjects(context.Context, string, authtypes.Relation, coretypes.Type) ([]*coretypes.Object, error)
// Updates the role metadata.
Update(context.Context, valuer.UUID, *authtypes.Role) error
// Patches the objects in authorization server associated with the given role and relation
PatchObjects(context.Context, valuer.UUID, string, authtypes.Relation, []*coretypes.Object, []*coretypes.Object) error
// Gets the objects associated with the given role and relation.
GetObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation) ([]*coretypes.Object, error)
}
// OnBeforeRoleDelete is a callback invoked before a role is deleted.
@@ -99,17 +93,15 @@ type Handler interface {
Get(http.ResponseWriter, *http.Request)
GetObjects(http.ResponseWriter, *http.Request)
List(http.ResponseWriter, *http.Request)
UpdateTransactions(http.ResponseWriter, *http.Request)
Patch(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)
PatchObjects(http.ResponseWriter, *http.Request)
Check(http.ResponseWriter, *http.Request)
GetObjects(http.ResponseWriter, *http.Request)
Update(http.ResponseWriter, *http.Request)
PatchObjects(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)
}

View File

@@ -83,15 +83,6 @@ func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.
return provider.store.Get(ctx, orgID, id)
}
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
role, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
return &authtypes.RoleWithTransactionGroups{Role: role, TransactionGroups: nil}, nil
}
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
return provider.store.GetByOrgIDAndName(ctx, orgID, name)
}
@@ -189,11 +180,7 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) UpdateTransactions(_ context.Context, _ valuer.UUID, _ valuer.UUID, _ []*authtypes.TransactionGroup) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
func (provider *provider) Patch(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}

View File

@@ -65,13 +65,53 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
return
}
roleWithTransactionGroups, err := handler.authz.GetWithTransactionGroups(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, roleWithTransactionGroups)
render.Success(rw, http.StatusOK, role)
}
func (handler *handler) GetObjects(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, ok := mux.Vars(r)["id"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
return
}
roleID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
relationStr, ok := mux.Vars(r)["relation"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleInvalidInput, "relation is missing from the request"))
return
}
relation, err := coretypes.NewVerb(relationStr)
if err != nil {
render.Error(rw, err)
return
}
objects, err := handler.authz.GetObjects(ctx, valuer.MustNewUUID(claims.OrgID), roleID, authtypes.Relation{Verb: relation})
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, coretypes.NewObjectGroupsFromObjects(objects))
}
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
@@ -91,7 +131,7 @@ func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, roles)
}
func (handler *handler) UpdateTransactions(rw http.ResponseWriter, r *http.Request) {
func (handler *handler) Patch(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
@@ -105,13 +145,77 @@ func (handler *handler) UpdateTransactions(rw http.ResponseWriter, r *http.Reque
return
}
req := new(authtypes.UpdatableTransactionGroups)
req := new(authtypes.PatchableRole)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
err = handler.authz.UpdateTransactions(ctx, valuer.MustNewUUID(claims.OrgID), id, req.TransactionGroups)
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(rw, err)
return
}
err = role.PatchMetadata(req.Description)
if err != nil {
render.Error(rw, err)
return
}
err = handler.authz.Patch(ctx, valuer.MustNewUUID(claims.OrgID), role)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) PatchObjects(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
relation, err := coretypes.NewVerb(mux.Vars(r)["relation"])
if err != nil {
render.Error(rw, err)
return
}
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(rw, err)
return
}
if err := role.ErrIfManaged(); err != nil {
render.Error(rw, err)
return
}
req := new(coretypes.PatchableObjects)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
additions, deletions, err := coretypes.NewPatchableObjects(req.Additions, req.Deletions, relation)
if err != nil {
render.Error(rw, err)
return
}
err = handler.authz.PatchObjects(ctx, valuer.MustNewUUID(claims.OrgID), role.Name, authtypes.Relation{Verb: relation}, additions, deletions)
if err != nil {
render.Error(rw, err)
return
@@ -172,136 +276,3 @@ func (handler *handler) Check(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, authtypes.NewGettableTransaction(results))
}
func (handler *handler) GetObjects(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, ok := mux.Vars(r)["id"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
return
}
roleID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
relationStr, ok := mux.Vars(r)["relation"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleInvalidInput, "relation is missing from the request"))
return
}
relation, err := coretypes.NewVerb(relationStr)
if err != nil {
render.Error(rw, err)
return
}
objects, err := handler.authz.GetObjects(ctx, valuer.MustNewUUID(claims.OrgID), roleID, authtypes.Relation{Verb: relation})
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, coretypes.NewObjectGroupsFromObjects(objects))
}
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
req := new(authtypes.UpdatableRole)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(rw, err)
return
}
err = role.Update(req.Description)
if err != nil {
render.Error(rw, err)
return
}
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), role)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) PatchObjects(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
relation, err := coretypes.NewVerb(mux.Vars(r)["relation"])
if err != nil {
render.Error(rw, err)
return
}
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(rw, err)
return
}
if err := role.ErrIfManaged(); err != nil {
render.Error(rw, err)
return
}
req := new(coretypes.PatchableObjects)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
additions, deletions, err := coretypes.NewPatchableObjects(req.Additions, req.Deletions, relation)
if err != nil {
render.Error(rw, err)
return
}
err = handler.authz.PatchObjects(ctx, valuer.MustNewUUID(claims.OrgID), role.Name, authtypes.Relation{Verb: relation}, additions, deletions)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}

View File

@@ -72,17 +72,12 @@ type Role struct {
OrgID valuer.UUID `bun:"org_id,type:string" json:"orgId" required:"true"`
}
type RoleWithTransactionGroups struct {
*Role
TransactionGroups []*TransactionGroup `json:"transactionGroups" required:"true" nullable:"false"`
}
type PostableRole struct {
Name string `json:"name" required:"true"`
Description string `json:"description"`
}
type UpdatableRole struct {
type PatchableRole struct {
Description string `json:"description" required:"true"`
}
@@ -102,13 +97,6 @@ func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID
}
}
func MakeRoleWithTransactionGroups(role *Role, transactionGroups []*TransactionGroup) *RoleWithTransactionGroups {
return &RoleWithTransactionGroups{
Role: role,
TransactionGroups: transactionGroups,
}
}
func NewManagedRoles(orgID valuer.UUID) []*Role {
return []*Role{
NewRole(SigNozAdminRoleName, SigNozAdminRoleDescription, RoleTypeManaged, orgID),
@@ -119,7 +107,7 @@ func NewManagedRoles(orgID valuer.UUID) []*Role {
}
func (role *Role) Update(description string) error {
func (role *Role) PatchMetadata(description string) error {
err := role.ErrIfManaged()
if err != nil {
return err
@@ -139,42 +127,46 @@ func (role *Role) ErrIfManaged() error {
}
func (role *PostableRole) UnmarshalJSON(data []byte) error {
type Alias PostableRole
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if temp.Name == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name is missing from the request")
}
if match := roleNameRegex.MatchString(temp.Name); !match {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name must contain only lowercase letters (a-z) and hyphens (-), and be at most 50 characters long.")
}
if strings.HasPrefix(temp.Name, managedRolePrefix) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "role name cannot start with %q as it is reserved for SigNoz managed roles.", managedRolePrefix)
}
role.Name = temp.Name
role.Description = temp.Description
return nil
}
func (role *UpdatableRole) UnmarshalJSON(data []byte) error {
type shadowUpdatableRole struct {
type shadowPostableRole struct {
Name string `json:"name"`
Description string `json:"description"`
}
var shadowRole shadowUpdatableRole
var shadowRole shadowPostableRole
if err := json.Unmarshal(data, &shadowRole); err != nil {
return err
}
if shadowRole.Name == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name is missing from the request")
}
if match := roleNameRegex.MatchString(shadowRole.Name); !match {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name must contain only lowercase letters (a-z) and hyphens (-), and be at most 50 characters long.")
}
if strings.HasPrefix(shadowRole.Name, managedRolePrefix) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "role name cannot start with %q as it is reserved for SigNoz managed roles.", managedRolePrefix)
}
role.Name = shadowRole.Name
role.Description = shadowRole.Description
return nil
}
func (role *PatchableRole) UnmarshalJSON(data []byte) error {
type shadowPatchableRole struct {
Description string `json:"description"`
}
var shadowRole shadowPatchableRole
if err := json.Unmarshal(data, &shadowRole); err != nil {
return err
}
if shadowRole.Description == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleEmptyPatch, "description must be present")
return errors.New(errors.TypeInvalidInput, ErrCodeRoleEmptyPatch, "empty role patch request received, description must be present")
}
role.Description = shadowRole.Description

View File

@@ -13,21 +13,12 @@ type Transaction struct {
Object coretypes.Object `json:"object" required:"true"`
}
type TransactionGroup struct {
Relation Relation `json:"relation" required:"true"`
ObjectGroup coretypes.ObjectGroup `json:"objectGroup" required:"true"`
}
type GettableTransaction struct {
Relation Relation `json:"relation" required:"true"`
Object coretypes.Object `json:"object" required:"true"`
Authorized bool `json:"authorized" required:"true"`
}
type UpdatableTransactionGroups struct {
TransactionGroups []*TransactionGroup `json:"transactionGroups" required:"true" nullable:"false"`
}
type TransactionWithAuthorization struct {
Transaction *Transaction
Authorized bool
@@ -41,18 +32,6 @@ func NewTransaction(relation Relation, object coretypes.Object) (*Transaction, e
return &Transaction{ID: valuer.GenerateUUID(), Relation: relation, Object: object}, nil
}
func NewTransactionGroup(relation Relation, objectGroup coretypes.ObjectGroup) (*TransactionGroup, error) {
if err := coretypes.ErrIfVerbNotValidForResource(relation.Verb, objectGroup.Resource); err != nil {
return nil, err
}
if _, err := coretypes.NewObjectsFromObjectGroup(objectGroup); err != nil {
return nil, err
}
return &TransactionGroup{Relation: relation, ObjectGroup: objectGroup}, nil
}
func NewGettableTransaction(results []*TransactionWithAuthorization) []*GettableTransaction {
gettableTransactions := make([]*GettableTransaction, len(results))
for i, result := range results {
@@ -86,26 +65,6 @@ func (transaction *Transaction) UnmarshalJSON(data []byte) error {
return nil
}
func (transactionGroup *TransactionGroup) UnmarshalJSON(data []byte) error {
var shadow = struct {
Relation Relation
ObjectGroup coretypes.ObjectGroup
}{}
err := json.Unmarshal(data, &shadow)
if err != nil {
return err
}
group, err := NewTransactionGroup(shadow.Relation, shadow.ObjectGroup)
if err != nil {
return err
}
*transactionGroup = *group
return nil
}
func (transaction *Transaction) TransactionKey() string {
return transaction.Relation.StringValue() + ":" + transaction.Object.Resource.Type.StringValue() + ":" + transaction.Object.Resource.Kind.String()
}

View File

@@ -47,62 +47,14 @@ func NewTuplesFromTransactions(transactions []*Transaction, subject string, orgI
return tuples, nil
}
func NewTuplesFromTransactionGroups(name string, orgID valuer.UUID, transactionGroups []*TransactionGroup) ([]*openfgav1.TupleKey, error) {
tuples := make([]*openfgav1.TupleKey, 0)
subject := MustNewSubject(coretypes.NewResourceRole(), name, orgID, &coretypes.VerbAssignee)
for _, transactionGroup := range transactionGroups {
if err := coretypes.ErrIfVerbNotValidForResource(transactionGroup.Relation.Verb, transactionGroup.ObjectGroup.Resource); err != nil {
return nil, err
}
resource, err := coretypes.NewResourceFromTypeAndKind(transactionGroup.ObjectGroup.Resource.Type, transactionGroup.ObjectGroup.Resource.Kind)
if err != nil {
return nil, err
}
objectGroupTuples := NewTuples(resource, subject, transactionGroup.Relation, transactionGroup.ObjectGroup.Selectors, orgID)
tuples = append(tuples, objectGroupTuples...)
}
return tuples, nil
}
func NewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) ([]*TransactionGroup, error) {
objectsByRelation := make(map[string][]*coretypes.Object)
for _, tuple := range tuples {
verb, err := coretypes.NewVerb(tuple.GetRelation())
if err != nil {
return nil, err
}
object, err := coretypes.NewObjectFromString(tuple.GetObject())
if err != nil {
return nil, err
}
objectsByRelation[verb.StringValue()] = append(objectsByRelation[verb.StringValue()], object)
}
transactionGroups := make([]*TransactionGroup, 0)
for _, verb := range coretypes.Verbs {
objects := objectsByRelation[verb.StringValue()]
if len(objects) == 0 {
continue
}
for _, objectGroup := range coretypes.NewObjectGroupsFromObjects(objects) {
transactionGroups = append(transactionGroups, &TransactionGroup{
Relation: Relation{Verb: verb},
ObjectGroup: *objectGroup,
})
}
}
return transactionGroups, nil
}
// NewTuplesFromTransactionsWithCorrelations converts transactions to tuples for BatchCheck,
// and for each transaction whose selector is not already a wildcard, generates an additional
// tuple with the wildcard selector. This ensures that permissions granted via wildcard
// selectors (e.g., dashboard:*) are checked alongside exact selectors (e.g., dashboard:abc-123).
//
// Returns:
// - tuples: all tuples to check (exact + correlated), keyed by transaction ID or generated correlation ID
// - correlations: maps transaction ID to a slice of correlation IDs for the additional tuples
func NewTuplesFromTransactionsWithCorrelations(transactions []*Transaction, subject string, orgID valuer.UUID) (tuples map[string]*openfgav1.TupleKey, correlations map[string][]string, err error) {
tuples = make(map[string]*openfgav1.TupleKey)
correlations = make(map[string][]string)
@@ -131,6 +83,10 @@ func NewTuplesFromTransactionsWithCorrelations(transactions []*Transaction, subj
return tuples, correlations, nil
}
// NewTuplesFromTransactionsWithManagedRoles converts transactions to tuples for BatchCheck.
// Direct role-assignment transactions (TypeRole + VerbAssignee) produce one tuple keyed by txn ID.
// Other transactions are expanded via managedRolesByTransaction into role-assignee checks, keyed by "txnID:roleName".
// Transactions with no managed role mapping are marked as pre-resolved (false) in the returned map.
func NewTuplesFromTransactionsWithManagedRoles(
transactions []*Transaction,
subject string,
@@ -175,6 +131,10 @@ func NewTuplesFromTransactionsWithManagedRoles(
return tuples, preResolved, roleCorrelations, nil
}
// NewTransactionWithAuthorizationFromBatchResults merges batch check results into an ordered
// slice of TransactionWithAuthorization matching the input transactions order.
// preResolved contains txn IDs whose authorization was determined without BatchCheck.
// roleCorrelations maps txn IDs to correlation IDs used for managed role checks.
func NewTransactionWithAuthorizationFromBatchResults(
transactions []*Transaction,
batchResults map[string]*TupleKeyAuthorization,

View File

@@ -9,7 +9,6 @@ import (
var (
ErrCodeInvalidPatchObject = errors.MustNewCode("authz_invalid_patch_objects")
ErrCodeInvalidObject = errors.MustNewCode("authz_invalid_object")
)
type Object struct {
@@ -45,46 +44,25 @@ func MustNewObject(resource ResourceRef, inputSelector string) *Object {
return object
}
// NewObjectFromString parses a tuple's object string back into an Object. Object strings are of the
// form "<type>:<...>/<kind>/<selector>" across all resource types (e.g.
// "metaresource:organization/<org>/dashboard/<uuid>", "organization:organization/<uuid>"); the kind
// is always the second-to-last "/" segment and the selector is the last, after stripping the type prefix.
func NewObjectFromString(input string) (*Object, error) {
typeAndRest := strings.SplitN(input, ":", 2)
if len(typeAndRest) != 2 {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidObject, "invalid object format: %s", input)
}
typed, err := NewType(typeAndRest[0])
if err != nil {
return nil, err
}
segments := strings.Split(typeAndRest[1], "/")
if len(segments) < 2 {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidObject, "invalid object format: %s", input)
}
kind, err := NewKind(segments[len(segments)-2])
if err != nil {
return nil, err
}
selector, err := typed.Selector(segments[len(segments)-1])
if err != nil {
return nil, err
}
return &Object{Resource: ResourceRef{Type: typed, Kind: kind}, Selector: selector}, nil
}
func MustNewObjectFromString(input string) *Object {
object, err := NewObjectFromString(input)
if err != nil {
panic(err)
parts := strings.Split(input, "/")
if len(parts) != 4 {
panic(errors.Newf(errors.TypeInternal, errors.CodeInternal, "invalid input format: %s", input))
}
return object
typeParts := strings.Split(parts[0], ":")
if len(typeParts) != 2 {
panic(errors.Newf(errors.TypeInternal, errors.CodeInternal, "invalid type format: %s", parts[0]))
}
resource := ResourceRef{
Type: MustNewType(typeParts[0]),
Kind: MustNewKind(parts[2]),
}
selector := resource.Type.MustSelector(parts[3])
return &Object{Resource: resource, Selector: selector}
}
func MustNewObjectsFromStringSlice(input []string) []*Object {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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