mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-19 23:10:25 +01:00
Compare commits
3 Commits
platform-p
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
139253ed6a | ||
|
|
32d1acdb2d | ||
|
|
32cbafe10f |
@@ -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:
|
||||
@@ -11107,7 +11058,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -11141,10 +11092,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 +11106,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesUpdatableRole'
|
||||
$ref: '#/components/schemas/AuthtypesPatchableRole'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
@@ -11200,7 +11151,7 @@ paths:
|
||||
- role:update
|
||||
- tokenizer:
|
||||
- role:update
|
||||
summary: Update role
|
||||
summary: Patch role
|
||||
tags:
|
||||
- role
|
||||
/api/v1/roles/{id}/relations/{relation}/objects:
|
||||
@@ -11282,7 +11233,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 +11306,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -9608,14 +9559,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 +9588,6 @@ export type PatchObjectsPathParameters = {
|
||||
id: string;
|
||||
relation: string;
|
||||
};
|
||||
export type UpdateRoleTransactionsPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetAllRoutePolicies200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -1,29 +1,67 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import {
|
||||
DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { cloneDeep, isArray, isFunction } from 'lodash-es';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import CheckboxFilterHeader from './CheckboxFilterHeader';
|
||||
import CheckboxValueRow from './CheckboxValueRow';
|
||||
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
|
||||
import useActiveQueryIndex from './useActiveQueryIndex';
|
||||
import useCheckboxDisclosure from './useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from './useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from './useCheckboxFilterState';
|
||||
import useCheckboxFilterValues from './useCheckboxFilterValues';
|
||||
import { isKeyMatch } from './utils';
|
||||
|
||||
import './Checkbox.styles.scss';
|
||||
|
||||
const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
|
||||
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
|
||||
|
||||
const SOURCES_WITH_EMPTY_STATE_ENABLED = [QuickFiltersSource.LOGS_EXPLORER];
|
||||
|
||||
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
|
||||
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
|
||||
|
||||
/**
|
||||
* Returns the correct NOT_IN operator value based on source.
|
||||
* InfraMonitoring backend expects 'nin', others expect 'not in'.
|
||||
*/
|
||||
function getNotInOperator(source: QuickFiltersSource): string {
|
||||
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
|
||||
return 'nin';
|
||||
}
|
||||
return getOperatorValue('NOT_IN');
|
||||
}
|
||||
|
||||
function setDefaultValues(
|
||||
values: string[],
|
||||
trueOrFalse: boolean,
|
||||
): Record<string, boolean> {
|
||||
const defaultState: Record<string, boolean> = {};
|
||||
values.forEach((val) => {
|
||||
defaultState[val] = trueOrFalse;
|
||||
});
|
||||
return defaultState;
|
||||
}
|
||||
interface ICheckboxProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
@@ -34,39 +72,194 @@ interface ICheckboxProps {
|
||||
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
const { source, filter, onFilterChange } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
// null = no user action, true = user opened, false = user closed
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
lastUsedQuery,
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
panelType,
|
||||
} = useQueryBuilder();
|
||||
|
||||
// Determine if we're in ListView mode
|
||||
const isListView = panelType === PANEL_TYPES.LIST;
|
||||
// In ListView mode, use index 0 for most sources; for TRACES_EXPLORER, use lastUsedQuery
|
||||
// Otherwise use lastUsedQuery for non-ListView modes
|
||||
const activeQueryIndex = useMemo(() => {
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
|
||||
// Check if this filter has active filters in the query
|
||||
const isSomeFilterPresentForCurrentAttribute = useMemo(
|
||||
() =>
|
||||
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
|
||||
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
),
|
||||
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
|
||||
);
|
||||
|
||||
// Derive isOpen from filter state + user action
|
||||
const isOpen = useMemo(() => {
|
||||
// If user explicitly toggled, respect that
|
||||
if (userToggleState !== null) {
|
||||
return userToggleState;
|
||||
}
|
||||
|
||||
// Auto-open if this filter has active filters in the query
|
||||
if (isSomeFilterPresentForCurrentAttribute) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise use default behavior (first 2 filters open)
|
||||
return filter.defaultOpen;
|
||||
}, [
|
||||
userToggleState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
visibleItemsCount,
|
||||
onToggleOpen,
|
||||
onShowMore,
|
||||
} = useCheckboxDisclosure({ filter, activeQueryIndex });
|
||||
filter.defaultOpen,
|
||||
]);
|
||||
|
||||
const { attributeValues, isLoading } = useCheckboxFilterValues({
|
||||
filter,
|
||||
source,
|
||||
searchText,
|
||||
isOpen,
|
||||
});
|
||||
const { data, isLoading } = useGetAggregateValues(
|
||||
{
|
||||
aggregateOperator: filter.aggregateOperator || 'noop',
|
||||
dataSource: filter.dataSource || DataSource.LOGS,
|
||||
aggregateAttribute: filter.aggregateAttribute || '',
|
||||
attributeKey: filter.attributeKey.key,
|
||||
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
|
||||
tagType: filter.attributeKey.type || '',
|
||||
searchText: searchText ?? '',
|
||||
},
|
||||
{
|
||||
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
|
||||
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
|
||||
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
|
||||
useGetQueryKeyValueSuggestions({
|
||||
key: filter.attributeKey.key,
|
||||
signal: filter.dataSource || DataSource.LOGS,
|
||||
signalSource: 'meter',
|
||||
options: {
|
||||
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { onChange, onClear } = useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
});
|
||||
const attributeValues: string[] = useMemo(() => {
|
||||
const dataType = filter.attributeKey.dataType || DataTypes.String;
|
||||
|
||||
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
|
||||
// Process the response data
|
||||
const responseData = keyValueSuggestions?.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
|
||||
// Generate options from string values - explicitly handle empty strings
|
||||
const stringOptions = stringValues
|
||||
// Strict filtering for empty string - we'll handle it as a special case if needed
|
||||
.filter(
|
||||
(value: string | null | undefined): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
);
|
||||
|
||||
// Generate options from number values
|
||||
const numberOptions = numberValues
|
||||
.filter(
|
||||
(value: number | null | undefined): value is number =>
|
||||
value !== null && value !== undefined,
|
||||
)
|
||||
.map((value: number) => value.toString());
|
||||
|
||||
// Combine all options and make sure we don't have duplicate labels
|
||||
return [...stringOptions, ...numberOptions];
|
||||
}
|
||||
|
||||
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
|
||||
return (data?.payload?.[key] || []).filter(
|
||||
(val) => val !== undefined && val !== null,
|
||||
);
|
||||
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
|
||||
|
||||
const setSearchTextDebounced = useDebouncedFn((...args) => {
|
||||
setSearchText(args[0] as string);
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
// derive the state of each filter key here in the renderer itself and keep it in sync with current query
|
||||
// also we need to keep a note of last focussed query.
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const currentFilterState = useMemo(() => {
|
||||
let filterState: Record<string, boolean> = setDefaultValues(
|
||||
attributeValues,
|
||||
false,
|
||||
);
|
||||
const filterSync = currentQuery?.builder.queryData?.[
|
||||
activeQueryIndex
|
||||
]?.filters?.items.find((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
if (filterSync) {
|
||||
if (SELECTED_OPERATORS.includes(filterSync.op)) {
|
||||
if (isArray(filterSync.value)) {
|
||||
filterSync.value.forEach((val) => {
|
||||
filterState[String(val)] = true;
|
||||
});
|
||||
} else if (typeof filterSync.value === 'string') {
|
||||
filterState[filterSync.value] = true;
|
||||
} else if (typeof filterSync.value === 'boolean') {
|
||||
filterState[String(filterSync.value)] = true;
|
||||
} else if (typeof filterSync.value === 'number') {
|
||||
filterState[String(filterSync.value)] = true;
|
||||
}
|
||||
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
|
||||
filterState = setDefaultValues(attributeValues, true);
|
||||
if (isArray(filterSync.value)) {
|
||||
filterSync.value.forEach((val) => {
|
||||
filterState[String(val)] = false;
|
||||
});
|
||||
} else if (typeof filterSync.value === 'string') {
|
||||
filterState[filterSync.value] = false;
|
||||
} else if (typeof filterSync.value === 'boolean') {
|
||||
filterState[String(filterSync.value)] = false;
|
||||
} else if (typeof filterSync.value === 'number') {
|
||||
filterState[String(filterSync.value)] = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filterState = setDefaultValues(attributeValues, true);
|
||||
}
|
||||
return filterState;
|
||||
}, [
|
||||
attributeValues,
|
||||
currentQuery?.builder.queryData,
|
||||
filter.attributeKey,
|
||||
activeQueryIndex,
|
||||
]);
|
||||
|
||||
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
|
||||
const isFilterDisabled = useMemo(
|
||||
() =>
|
||||
(currentQuery?.builder?.queryData?.[
|
||||
activeQueryIndex
|
||||
]?.filters?.items?.filter((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
)?.length || 0) > 1,
|
||||
|
||||
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
|
||||
);
|
||||
|
||||
// variable to check if the current filter has multiple values to its name in the key op value section
|
||||
const isMultipleValuesTrueForTheKey =
|
||||
Object.values(currentFilterState).filter((val) => val).length > 1;
|
||||
|
||||
// Sort checked items to the top, then unchecked items
|
||||
const currentAttributeKeys = useMemo(() => {
|
||||
const checkedValues = attributeValues.filter(
|
||||
@@ -84,6 +277,293 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
[currentAttributeKeys, currentFilterState],
|
||||
);
|
||||
|
||||
const handleClearFilterAttribute = (): void => {
|
||||
const preparedQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => ({
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
|
||||
filter.attributeKey.key,
|
||||
]),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
idx === activeQueryIndex
|
||||
? item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
|
||||
) || []
|
||||
: [...(item.filters?.items || [])],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
if (onFilterChange && isFunction(onFilterChange)) {
|
||||
onFilterChange(preparedQuery);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(preparedQuery);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): void => {
|
||||
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
|
||||
|
||||
// if only or all are clicked we do not need to worry about anything just override whatever we have
|
||||
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
|
||||
if (isOnlyOrAllClicked && query?.filters?.items) {
|
||||
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only';
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
|
||||
if (isOnlyOrAll === 'Only') {
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = [...query.filters.items, newFilterItem];
|
||||
}
|
||||
} else if (query?.filters?.items) {
|
||||
if (
|
||||
query.filters?.items?.some((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
)
|
||||
) {
|
||||
// if there is already a running filter for the current attribute key then
|
||||
// we split the cases by which particular operator is present right now!
|
||||
const currentFilter = query.filters?.items?.find((q) =>
|
||||
isKeyMatch(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (currentFilter) {
|
||||
const runningOperator = currentFilter?.op;
|
||||
switch (runningOperator) {
|
||||
case 'in':
|
||||
if (checked) {
|
||||
// if it's an IN operator then if we are checking another value it get's added to the
|
||||
// filter clause. example - key IN [value1, currentSelectedValue]
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// if the current state wasn't an array we make it one and add our value
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (!checked) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// if not an array remove the whole thing altogether!
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'nin':
|
||||
case 'not in':
|
||||
// if the current running operator is NIN then when unchecking the value it gets
|
||||
// added to the clause like key NIN [value1 , currentUnselectedValue]
|
||||
if (!checked) {
|
||||
// in case of array add the currentUnselectedValue to the list.
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// in case of not an array make it one!
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (checked) {
|
||||
// opposite of above!
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value === value ? null : currentFilter.value,
|
||||
};
|
||||
if (newFilter.value === null && query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case '=':
|
||||
if (checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (!checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case '!=':
|
||||
if (!checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getNotInOperator(source),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// case - when there is no filter for the current key that means all are selected right now.
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = [...query.filters.items, newFilterItem];
|
||||
}
|
||||
}
|
||||
const finalQuery = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
...currentQuery.builder.queryData.map((q, idx) => {
|
||||
if (idx === activeQueryIndex) {
|
||||
return query;
|
||||
}
|
||||
return q;
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
if (onFilterChange && isFunction(onFilterChange)) {
|
||||
onFilterChange(finalQuery);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(finalQuery);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmptyStateWithDocsEnabled =
|
||||
SOURCES_WITH_EMPTY_STATE_ENABLED.includes(source) &&
|
||||
!searchText &&
|
||||
@@ -91,19 +571,48 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="checkbox-filter">
|
||||
<CheckboxFilterHeader
|
||||
title={filter.title}
|
||||
isOpen={isOpen}
|
||||
showClearAll={!!attributeValues.length}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
/>
|
||||
{isOpen && isLoading && !attributeValues.length && (
|
||||
<section className="loading">
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
<section
|
||||
className="filter-header-checkbox"
|
||||
onClick={(): void => {
|
||||
if (isOpen) {
|
||||
setUserToggleState(false);
|
||||
setVisibleItemsCount(10);
|
||||
} else {
|
||||
setUserToggleState(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section className="left-action">
|
||||
{isOpen ? (
|
||||
<ChevronDown size={13} cursor="pointer" />
|
||||
) : (
|
||||
<ChevronRight size={13} cursor="pointer" />
|
||||
)}
|
||||
<Typography.Text className="title">{filter.title}</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
{isOpen && !isLoading && (
|
||||
<section className="right-action">
|
||||
{isOpen && !!attributeValues.length && (
|
||||
<Typography.Text
|
||||
className="clear-all"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleClearFilterAttribute();
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</Typography.Text>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
{isOpen &&
|
||||
(isLoading || isLoadingKeyValueSuggestions) &&
|
||||
!attributeValues.length && (
|
||||
<section className="loading">
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
</section>
|
||||
)}
|
||||
{isOpen && !isLoading && !isLoadingKeyValueSuggestions && (
|
||||
<>
|
||||
{!isEmptyStateWithDocsEnabled && (
|
||||
<section className="search">
|
||||
@@ -125,24 +634,48 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
data-testid="filter-separator"
|
||||
/>
|
||||
)}
|
||||
<CheckboxValueRow
|
||||
value={value}
|
||||
checked={currentFilterState[value]}
|
||||
disabled={isFilterDisabled}
|
||||
title={filter.title}
|
||||
onlyButtonLabel={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'
|
||||
}
|
||||
customRendererForValue={filter.customRendererForValue}
|
||||
onCheckboxChange={(checked): void => onChange(value, checked, false)}
|
||||
onOnlyOrAllClick={(): void =>
|
||||
onChange(value, currentFilterState[value], true)
|
||||
}
|
||||
/>
|
||||
<div className="value">
|
||||
<Checkbox
|
||||
onChange={(checked): void =>
|
||||
onChange(value, checked === true, false)
|
||||
}
|
||||
value={currentFilterState[value]}
|
||||
disabled={isFilterDisabled}
|
||||
className="check-box"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'checkbox-value-section',
|
||||
isFilterDisabled ? 'filter-disabled' : '',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
if (isFilterDisabled) {
|
||||
return;
|
||||
}
|
||||
onChange(value, currentFilterState[value], true);
|
||||
}}
|
||||
>
|
||||
<div className={`${filter.title} label-${value}`} />
|
||||
{filter.customRendererForValue ? (
|
||||
filter.customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</section>
|
||||
@@ -155,7 +688,10 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
)}
|
||||
{visibleItemsCount < attributeValues?.length && (
|
||||
<section className="show-more">
|
||||
<Typography.Text className="show-more-text" onClick={onShowMore}>
|
||||
<Typography.Text
|
||||
className="show-more-text"
|
||||
onClick={(): void => setVisibleItemsCount((prev) => prev + 10)}
|
||||
>
|
||||
Show More...
|
||||
</Typography.Text>
|
||||
</section>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
interface CheckboxFilterHeaderProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
showClearAll: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
function CheckboxFilterHeader({
|
||||
title,
|
||||
isOpen,
|
||||
showClearAll,
|
||||
onToggleOpen,
|
||||
onClear,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
return (
|
||||
<section className="filter-header-checkbox" onClick={onToggleOpen}>
|
||||
<section className="left-action">
|
||||
{isOpen ? (
|
||||
<ChevronDown size={13} cursor="pointer" />
|
||||
) : (
|
||||
<ChevronRight size={13} cursor="pointer" />
|
||||
)}
|
||||
<Typography.Text className="title">{title}</Typography.Text>
|
||||
</section>
|
||||
<section className="right-action">
|
||||
{isOpen && showClearAll && (
|
||||
<Typography.Text
|
||||
className="clear-all"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</Typography.Text>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxFilterHeader;
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Button } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
interface CheckboxValueRowProps {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
title: string;
|
||||
onlyButtonLabel: string;
|
||||
customRendererForValue?: (value: string) => JSX.Element;
|
||||
onCheckboxChange: (checked: boolean) => void;
|
||||
onOnlyOrAllClick: () => void;
|
||||
}
|
||||
|
||||
function CheckboxValueRow({
|
||||
value,
|
||||
checked,
|
||||
disabled,
|
||||
title,
|
||||
onlyButtonLabel,
|
||||
customRendererForValue,
|
||||
onCheckboxChange,
|
||||
onOnlyOrAllClick,
|
||||
}: CheckboxValueRowProps): JSX.Element {
|
||||
return (
|
||||
<div className="value">
|
||||
<Checkbox
|
||||
onChange={(isChecked): void => onCheckboxChange(isChecked === true)}
|
||||
value={checked}
|
||||
disabled={disabled}
|
||||
className="check-box"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cx('checkbox-value-section', disabled ? 'filter-disabled' : '')}
|
||||
onClick={(): void => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onOnlyOrAllClick();
|
||||
}}
|
||||
>
|
||||
<div className={`${title} label-${value}`} />
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{onlyButtonLabel}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CheckboxValueRow.defaultProps = {
|
||||
customRendererForValue: undefined,
|
||||
};
|
||||
|
||||
export default CheckboxValueRow;
|
||||
@@ -1,417 +0,0 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { cloneDeep, isArray } from 'lodash-es';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { isKeyMatch } from './utils';
|
||||
|
||||
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
|
||||
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
|
||||
|
||||
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
|
||||
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
|
||||
|
||||
/**
|
||||
* Returns the correct NOT_IN operator value based on source.
|
||||
* InfraMonitoring backend expects 'nin', others expect 'not in'.
|
||||
*/
|
||||
export function getNotInOperator(source: QuickFiltersSource): string {
|
||||
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
|
||||
return 'nin';
|
||||
}
|
||||
return getOperatorValue('NOT_IN');
|
||||
}
|
||||
|
||||
function setDefaultValues(
|
||||
values: string[],
|
||||
trueOrFalse: boolean,
|
||||
): Record<string, boolean> {
|
||||
const defaultState: Record<string, boolean> = {};
|
||||
values.forEach((val) => {
|
||||
defaultState[val] = trueOrFalse;
|
||||
});
|
||||
return defaultState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the checked/unchecked state for each attribute value by reading the
|
||||
* active filter clause for this attribute key out of the query.
|
||||
*
|
||||
* - No matching clause -> every value is checked (all selected).
|
||||
* - IN / `=` clause -> only the listed values are checked.
|
||||
* - NOT IN / `!=` clause -> every value is checked except the excluded ones.
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function deriveCheckboxState({
|
||||
attributeValues,
|
||||
filterItems,
|
||||
filterKey,
|
||||
}: {
|
||||
attributeValues: string[];
|
||||
filterItems: TagFilterItem[] | undefined;
|
||||
filterKey: string;
|
||||
}): Record<string, boolean> {
|
||||
let filterState: Record<string, boolean> = setDefaultValues(
|
||||
attributeValues,
|
||||
false,
|
||||
);
|
||||
const filterSync = filterItems?.find((item) =>
|
||||
isKeyMatch(item.key?.key, filterKey),
|
||||
);
|
||||
|
||||
if (filterSync) {
|
||||
if (SELECTED_OPERATORS.includes(filterSync.op)) {
|
||||
if (isArray(filterSync.value)) {
|
||||
filterSync.value.forEach((val) => {
|
||||
filterState[String(val)] = true;
|
||||
});
|
||||
} else if (typeof filterSync.value === 'string') {
|
||||
filterState[filterSync.value] = true;
|
||||
} else if (typeof filterSync.value === 'boolean') {
|
||||
filterState[String(filterSync.value)] = true;
|
||||
} else if (typeof filterSync.value === 'number') {
|
||||
filterState[String(filterSync.value)] = true;
|
||||
}
|
||||
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
|
||||
filterState = setDefaultValues(attributeValues, true);
|
||||
if (isArray(filterSync.value)) {
|
||||
filterSync.value.forEach((val) => {
|
||||
filterState[String(val)] = false;
|
||||
});
|
||||
} else if (typeof filterSync.value === 'string') {
|
||||
filterState[filterSync.value] = false;
|
||||
} else if (typeof filterSync.value === 'boolean') {
|
||||
filterState[String(filterSync.value)] = false;
|
||||
} else if (typeof filterSync.value === 'number') {
|
||||
filterState[String(filterSync.value)] = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filterState = setDefaultValues(attributeValues, true);
|
||||
}
|
||||
return filterState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new query with every clause for this attribute key removed, both
|
||||
* from the structured filter items and the raw filter expression.
|
||||
*/
|
||||
export function clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filter,
|
||||
activeQueryIndex,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
filter: IQuickFiltersConfig;
|
||||
activeQueryIndex: number;
|
||||
}): Query {
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => ({
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
|
||||
filter.attributeKey.key,
|
||||
]),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
idx === activeQueryIndex
|
||||
? item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
|
||||
) || []
|
||||
: [...(item.filters?.items || [])],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function applyCheckboxToggle({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
activeQueryIndex: number;
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
attributeValues: string[];
|
||||
value: string;
|
||||
checked: boolean;
|
||||
isOnlyOrAllClicked: boolean;
|
||||
}): Query {
|
||||
const activeItems =
|
||||
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
|
||||
|
||||
const isSomeFilterPresentForCurrentAttribute = !!activeItems?.some((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
const currentFilterState = deriveCheckboxState({
|
||||
attributeValues,
|
||||
filterItems: activeItems,
|
||||
filterKey: filter.attributeKey.key,
|
||||
});
|
||||
|
||||
const isMultipleValuesTrueForTheKey =
|
||||
Object.values(currentFilterState).filter((val) => val).length > 1;
|
||||
|
||||
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
|
||||
|
||||
// if only or all are clicked we do not need to worry about anything just override whatever we have
|
||||
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
|
||||
if (isOnlyOrAllClicked && query?.filters?.items) {
|
||||
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only';
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(query.filter.expression, [
|
||||
filter.attributeKey.key,
|
||||
]);
|
||||
}
|
||||
|
||||
if (isOnlyOrAll === 'Only') {
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = [...query.filters.items, newFilterItem];
|
||||
}
|
||||
} else if (query?.filters?.items) {
|
||||
if (
|
||||
query.filters?.items?.some((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
)
|
||||
) {
|
||||
// if there is already a running filter for the current attribute key then
|
||||
// we split the cases by which particular operator is present right now!
|
||||
const currentFilter = query.filters?.items?.find((q) =>
|
||||
isKeyMatch(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (currentFilter) {
|
||||
const runningOperator = currentFilter?.op;
|
||||
switch (runningOperator) {
|
||||
case 'in':
|
||||
if (checked) {
|
||||
// if it's an IN operator then if we are checking another value it get's added to the
|
||||
// filter clause. example - key IN [value1, currentSelectedValue]
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// if the current state wasn't an array we make it one and add our value
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (!checked) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// if not an array remove the whole thing altogether!
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'nin':
|
||||
case 'not in':
|
||||
// if the current running operator is NIN then when unchecking the value it gets
|
||||
// added to the clause like key NIN [value1 , currentUnselectedValue]
|
||||
if (!checked) {
|
||||
// in case of array add the currentUnselectedValue to the list.
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// in case of not an array make it one!
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (checked) {
|
||||
// opposite of above!
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value === value ? null : currentFilter.value,
|
||||
};
|
||||
if (newFilter.value === null && query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case '=':
|
||||
if (checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (!checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case '!=':
|
||||
if (!checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getNotInOperator(source),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// case - when there is no filter for the current key that means all are selected right now.
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = [...query.filters.items, newFilterItem];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
...currentQuery.builder.queryData.map((q, idx) => {
|
||||
if (idx === activeQueryIndex) {
|
||||
return query;
|
||||
}
|
||||
return q;
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
/**
|
||||
* Resolves which query-builder query index the checkbox filter reads from and
|
||||
* writes to.
|
||||
*
|
||||
* In ListView most sources use index 0; TRACES_EXPLORER and every non-ListView
|
||||
* mode track the last focused query.
|
||||
*/
|
||||
function useActiveQueryIndex(source: QuickFiltersSource): number {
|
||||
const { lastUsedQuery, panelType } = useQueryBuilder();
|
||||
const isListView = panelType === PANEL_TYPES.LIST;
|
||||
|
||||
return useMemo(() => {
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
}
|
||||
|
||||
export default useActiveQueryIndex;
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import { isKeyMatch } from './utils';
|
||||
|
||||
const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
|
||||
|
||||
interface UseCheckboxDisclosureProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
activeQueryIndex: number;
|
||||
}
|
||||
|
||||
interface UseCheckboxDisclosureReturn {
|
||||
isOpen: boolean;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
visibleItemsCount: number;
|
||||
onToggleOpen: () => void;
|
||||
onShowMore: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the open/collapsed state of a checkbox filter section and how many
|
||||
* values are visible.
|
||||
*
|
||||
* Auto-opens when the query already has a clause for this attribute, otherwise
|
||||
* falls back to `filter.defaultOpen`. An explicit user toggle always wins.
|
||||
* Collapsing resets the visible count.
|
||||
*/
|
||||
function useCheckboxDisclosure({
|
||||
filter,
|
||||
activeQueryIndex,
|
||||
}: UseCheckboxDisclosureProps): UseCheckboxDisclosureReturn {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
// null = no user action, true = user opened, false = user closed
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(
|
||||
DEFAULT_VISIBLE_ITEMS_COUNT,
|
||||
);
|
||||
|
||||
const isSomeFilterPresentForCurrentAttribute = useMemo(
|
||||
() =>
|
||||
!!currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
|
||||
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
),
|
||||
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
|
||||
);
|
||||
|
||||
const isOpen = useMemo(() => {
|
||||
// If user explicitly toggled, respect that
|
||||
if (userToggleState !== null) {
|
||||
return userToggleState;
|
||||
}
|
||||
|
||||
// Auto-open if this filter has active filters in the query
|
||||
if (isSomeFilterPresentForCurrentAttribute) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise use default behavior (first 2 filters open)
|
||||
return filter.defaultOpen;
|
||||
}, [
|
||||
userToggleState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
filter.defaultOpen,
|
||||
]);
|
||||
|
||||
const onToggleOpen = (): void => {
|
||||
if (isOpen) {
|
||||
setUserToggleState(false);
|
||||
setVisibleItemsCount(DEFAULT_VISIBLE_ITEMS_COUNT);
|
||||
} else {
|
||||
setUserToggleState(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onShowMore = (): void => {
|
||||
setVisibleItemsCount((prev) => prev + DEFAULT_VISIBLE_ITEMS_COUNT);
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
visibleItemsCount,
|
||||
onToggleOpen,
|
||||
onShowMore,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCheckboxDisclosure;
|
||||
@@ -1,78 +0,0 @@
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { isFunction } from 'lodash-es';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
applyCheckboxToggle,
|
||||
clearFilterFromQuery,
|
||||
} from './checkboxFilterQuery';
|
||||
|
||||
interface UseCheckboxFilterActionsProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
attributeValues: string[];
|
||||
activeQueryIndex: number;
|
||||
onFilterChange?: ((query: Query) => void) | null;
|
||||
}
|
||||
|
||||
interface UseCheckboxFilterActionsReturn {
|
||||
onChange: (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the pure checkbox query algebra to query-builder dispatch: the
|
||||
* caller-provided `onFilterChange` when present, otherwise a URL redirect.
|
||||
*/
|
||||
function useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
}: UseCheckboxFilterActionsProps): UseCheckboxFilterActionsReturn {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const dispatch = (query: Query): void => {
|
||||
if (onFilterChange && isFunction(onFilterChange)) {
|
||||
onFilterChange(query);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(query);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
): void => {
|
||||
dispatch(
|
||||
applyCheckboxToggle({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onClear = (): void => {
|
||||
dispatch(clearFilterFromQuery({ currentQuery, filter, activeQueryIndex }));
|
||||
};
|
||||
|
||||
return { onChange, onClear };
|
||||
}
|
||||
|
||||
export default useCheckboxFilterActions;
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import { deriveCheckboxState } from './checkboxFilterQuery';
|
||||
import { isKeyMatch } from './utils';
|
||||
|
||||
interface UseCheckboxFilterStateProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
attributeValues: string[];
|
||||
activeQueryIndex: number;
|
||||
}
|
||||
|
||||
interface UseCheckboxFilterStateReturn {
|
||||
currentFilterState: Record<string, boolean>;
|
||||
isFilterDisabled: boolean;
|
||||
isMultipleValuesTrueForTheKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the active query and derives the per-value checked state for this
|
||||
* attribute, whether the filter is disabled (same key used more than once in
|
||||
* the filter bar), and whether more than one value is currently selected.
|
||||
*/
|
||||
function useCheckboxFilterState({
|
||||
filter,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
}: UseCheckboxFilterStateProps): UseCheckboxFilterStateReturn {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
// derive the state of each filter key here and keep it in sync with current query
|
||||
const currentFilterState = useMemo(
|
||||
() =>
|
||||
deriveCheckboxState({
|
||||
attributeValues,
|
||||
filterItems:
|
||||
currentQuery?.builder.queryData?.[activeQueryIndex]?.filters?.items,
|
||||
filterKey: filter.attributeKey.key,
|
||||
}),
|
||||
[
|
||||
attributeValues,
|
||||
currentQuery?.builder.queryData,
|
||||
filter.attributeKey,
|
||||
activeQueryIndex,
|
||||
],
|
||||
);
|
||||
|
||||
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
|
||||
const isFilterDisabled = useMemo(
|
||||
() =>
|
||||
(currentQuery?.builder?.queryData?.[
|
||||
activeQueryIndex
|
||||
]?.filters?.items?.filter((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
)?.length || 0) > 1,
|
||||
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
|
||||
);
|
||||
|
||||
// whether the current filter has multiple values to its name in the key op value section
|
||||
const isMultipleValuesTrueForTheKey =
|
||||
Object.values(currentFilterState).filter((val) => val).length > 1;
|
||||
|
||||
return {
|
||||
currentFilterState,
|
||||
isFilterDisabled,
|
||||
isMultipleValuesTrueForTheKey,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCheckboxFilterState;
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
interface UseCheckboxFilterValuesProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
searchText: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
interface UseCheckboxFilterValuesReturn {
|
||||
attributeValues: string[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function useCheckboxFilterValues({
|
||||
filter,
|
||||
source,
|
||||
searchText,
|
||||
isOpen,
|
||||
}: UseCheckboxFilterValuesProps): UseCheckboxFilterValuesReturn {
|
||||
const { data, isLoading } = useGetAggregateValues(
|
||||
{
|
||||
aggregateOperator: filter.aggregateOperator || 'noop',
|
||||
dataSource: filter.dataSource || DataSource.LOGS,
|
||||
aggregateAttribute: filter.aggregateAttribute || '',
|
||||
attributeKey: filter.attributeKey.key,
|
||||
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
|
||||
tagType: filter.attributeKey.type || '',
|
||||
searchText: searchText ?? '',
|
||||
},
|
||||
{
|
||||
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
|
||||
useGetQueryKeyValueSuggestions({
|
||||
key: filter.attributeKey.key,
|
||||
signal: filter.dataSource || DataSource.LOGS,
|
||||
signalSource: 'meter',
|
||||
options: {
|
||||
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const attributeValues: string[] = useMemo(() => {
|
||||
const dataType = filter.attributeKey.dataType || DataTypes.String;
|
||||
|
||||
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
|
||||
// Process the response data
|
||||
const responseData = keyValueSuggestions?.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
|
||||
// Generate options from string values - explicitly handle empty strings
|
||||
const stringOptions = stringValues
|
||||
// Strict filtering for empty string - we'll handle it as a special case if needed
|
||||
.filter(
|
||||
(value: string | null | undefined): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
);
|
||||
|
||||
// Generate options from number values
|
||||
const numberOptions = numberValues
|
||||
.filter(
|
||||
(value: number | null | undefined): value is number =>
|
||||
value !== null && value !== undefined,
|
||||
)
|
||||
.map((value: number) => value.toString());
|
||||
|
||||
// Combine all options and make sure we don't have duplicate labels
|
||||
return [...stringOptions, ...numberOptions];
|
||||
}
|
||||
|
||||
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
|
||||
return (data?.payload?.[key] || []).filter(
|
||||
(val) => val !== undefined && val !== null,
|
||||
);
|
||||
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
|
||||
|
||||
return {
|
||||
attributeValues,
|
||||
isLoading: isLoading || isLoadingKeyValueSuggestions,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCheckboxFilterValues;
|
||||
@@ -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,
|
||||
|
||||
@@ -20,7 +20,7 @@ import APIError from 'types/api/error';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
|
||||
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import VariablesBar from '../VariablesBar/VariablesBar';
|
||||
|
||||
import styles from './DashboardPageToolbar.module.scss';
|
||||
|
||||
@@ -53,10 +53,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
|
||||
// Single global fetch of the public-sharing meta (the drawer reuses this cache);
|
||||
// drives the public-access badge.
|
||||
const { isPublic: isPublicDashboard } = usePublicDashboardMeta(id);
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
|
||||
@@ -122,7 +118,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublicDashboard}
|
||||
isPublicDashboard={false}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
@@ -142,6 +138,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sortBy } from 'lodash-es';
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
@@ -76,3 +77,26 @@ export function emptyVariableFormModel(): VariableFormModel {
|
||||
dynamicSignal: 'traces',
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps the dynamic-variable signal to the field-values API signal. */
|
||||
export function signalForApi(
|
||||
signal: TelemetrySignal,
|
||||
): TelemetrySignal | undefined {
|
||||
return signal;
|
||||
}
|
||||
|
||||
type SortableValues = (string | number | boolean)[];
|
||||
|
||||
/** Sorts option/preview values by the variable's chosen order (no-op when disabled). */
|
||||
export function sortValuesByOrder(
|
||||
values: SortableValues,
|
||||
sort: VariableSort,
|
||||
): SortableValues {
|
||||
if (sort === 'ASC') {
|
||||
return sortBy(values);
|
||||
}
|
||||
if (sort === 'DESC') {
|
||||
return sortBy(values).reverse();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useMemo } from 'react';
|
||||
import { SolidInfoCircle } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
|
||||
import { Tooltip } from 'antd';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { sortValuesByOrder } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
|
||||
import DynamicSelector from './selectors/DynamicSelector';
|
||||
import QuerySelector from './selectors/QuerySelector';
|
||||
import TextSelector from './selectors/TextSelector';
|
||||
import ValueSelector from './selectors/ValueSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
interface VariableSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** All variables (Dynamic uses them to scope options by sibling selections). */
|
||||
variables: VariableFormModel[];
|
||||
/** Names this variable depends on (for Query gating). */
|
||||
parents: string[];
|
||||
/** All current selections (Query passes them as the request payload). */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/** One labelled variable control; dispatches on the variable type. */
|
||||
function VariableSelector({
|
||||
variable,
|
||||
variables,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: VariableSelectorProps): JSX.Element {
|
||||
const customOptions = useMemo(
|
||||
() =>
|
||||
variable.type === 'CUSTOM'
|
||||
? sortValuesByOrder(
|
||||
commaValuesParser(variable.customValue),
|
||||
variable.sort,
|
||||
).map(String)
|
||||
: [],
|
||||
[variable],
|
||||
);
|
||||
|
||||
const renderControl = (): JSX.Element => {
|
||||
switch (variable.type) {
|
||||
case 'TEXT':
|
||||
return (
|
||||
<TextSelector
|
||||
selection={selection}
|
||||
defaultValue={variable.textValue}
|
||||
onChange={onChange}
|
||||
testId={`variable-input-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
case 'QUERY':
|
||||
return (
|
||||
<QuerySelector
|
||||
variable={variable}
|
||||
parents={parents}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'DYNAMIC':
|
||||
return (
|
||||
<DynamicSelector
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'CUSTOM':
|
||||
default:
|
||||
return (
|
||||
<ValueSelector
|
||||
options={customOptions}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.variableItem}
|
||||
data-testid={`variable-${variable.name}`}
|
||||
>
|
||||
<Typography.Text className={styles.variableName}>
|
||||
${variable.name}
|
||||
{variable.description ? (
|
||||
<Tooltip title={variable.description}>
|
||||
<SolidInfoCircle className={styles.infoIcon} size="md" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
|
||||
<div className={styles.variableValue}>{renderControl()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableSelector;
|
||||
@@ -0,0 +1,71 @@
|
||||
/* Mirrors the V1 dashboard variable bar: each variable is a connected pill —
|
||||
a robin `$name` segment joined to a value segment. */
|
||||
/* Sits inside the already-padded sticky toolbar section, so it only needs a top
|
||||
gap from the tags — horizontal/bottom padding comes from the toolbar. */
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.variableItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.variableName {
|
||||
display: flex;
|
||||
min-width: 56px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px 0 0 2px;
|
||||
background: var(--l3-background);
|
||||
color: var(--bg-robin-300);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
margin-left: 4px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.variableValue {
|
||||
display: flex;
|
||||
min-width: 120px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-left: none;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
outline: 1px solid var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
|
||||
/* Inner control fills the value segment; the segment provides the frame, so the
|
||||
control itself is borderless/transparent. */
|
||||
.control {
|
||||
width: 100%;
|
||||
min-width: 120px;
|
||||
|
||||
:global(.ant-select-selector),
|
||||
:global(.ant-input),
|
||||
&:global(.ant-input) {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useVariableSelection } from './useVariableSelection';
|
||||
import VariableSelector from './VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime variable selector bar shown above the panels. Renders one control per
|
||||
* dashboard variable; selections live in the store + URL (never the spec).
|
||||
*/
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const { variables, dependencyData, selection, setSelection } =
|
||||
useVariableSelection(dashboard);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.bar} data-testid="dashboard-variables-bar">
|
||||
{variables.map((variable) => (
|
||||
<VariableSelector
|
||||
key={variable.name}
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
parents={dependencyData.parentGraph[variable.name] ?? []}
|
||||
selections={selection}
|
||||
selection={
|
||||
selection[variable.name] ?? {
|
||||
value: variable.multiSelect ? [] : '',
|
||||
allSelected: false,
|
||||
}
|
||||
}
|
||||
onChange={(next): void => setSelection(variable.name, next)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesBar;
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelectionMap } from './selectionTypes';
|
||||
|
||||
function formatQueryValue(val: string): string {
|
||||
const num = Number(val);
|
||||
if (!Number.isNaN(num) && Number.isFinite(num)) {
|
||||
return val;
|
||||
}
|
||||
return `'${val.replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
function buildQueryPart(attribute: string, values: string[]): string {
|
||||
const formatted = values.map(formatQueryValue);
|
||||
if (formatted.length === 1) {
|
||||
return `${attribute} = ${formatted[0]}`;
|
||||
}
|
||||
return `${attribute} IN [${formatted.join(', ')}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a filter expression from the OTHER dynamic variables' current
|
||||
* selections (e.g. `k8s.namespace.name IN ['prod'] AND service = 'api'`), so a
|
||||
* dynamic variable's option list is scoped by its sibling selections. Variables
|
||||
* in the ALL state, with no selection, or non-dynamic are skipped. Ported from
|
||||
* the V1 dynamic-variable runtime.
|
||||
*/
|
||||
export function buildExistingDynamicVariableQuery(
|
||||
variables: VariableFormModel[],
|
||||
selections: VariableSelectionMap,
|
||||
currentName: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
variables.forEach((variable) => {
|
||||
if (
|
||||
variable.name === currentName ||
|
||||
variable.type !== 'DYNAMIC' ||
|
||||
!variable.dynamicAttribute
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const selection = selections[variable.name];
|
||||
if (!selection || selection.allSelected) {
|
||||
return;
|
||||
}
|
||||
const raw = Array.isArray(selection.value)
|
||||
? selection.value
|
||||
: [selection.value];
|
||||
const valid = raw
|
||||
.filter((v) => v !== null && v !== undefined && v !== '')
|
||||
.map((v) => String(v));
|
||||
if (valid.length > 0) {
|
||||
parts.push(buildQueryPart(variable.dynamicAttribute, valid));
|
||||
}
|
||||
});
|
||||
return parts.join(' AND ');
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** A user-selected variable value at runtime (not persisted to the spec). */
|
||||
export type SelectedVariableValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| (string | number | boolean)[]
|
||||
| null;
|
||||
|
||||
export interface VariableSelection {
|
||||
value: SelectedVariableValue;
|
||||
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
|
||||
allSelected: boolean;
|
||||
}
|
||||
|
||||
/** Selected values for a dashboard's variables, keyed by variable name. */
|
||||
export type VariableSelectionMap = Record<string, VariableSelection>;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
|
||||
/** A selection counts as resolved (usable as a parent value) when it's non-empty. */
|
||||
export function isResolved(selection?: VariableSelection): boolean {
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
if (selection.allSelected) {
|
||||
return true;
|
||||
}
|
||||
const { value } = selection;
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return value !== '' && value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
|
||||
export function selectionToPayload(
|
||||
selection: VariableSelectionMap,
|
||||
): Record<string, SelectedVariableValue> {
|
||||
const payload: Record<string, SelectedVariableValue> = {};
|
||||
Object.entries(selection).forEach(([name, sel]) => {
|
||||
payload[name] = sel.value;
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
signalForApi,
|
||||
sortValuesByOrder,
|
||||
} from '../../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
|
||||
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface DynamicSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** All variables + current selections, to scope options by sibling dynamics. */
|
||||
variables: VariableFormModel[];
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic-variable options sourced from live telemetry field values for the
|
||||
* chosen signal + attribute, scoped by the other dynamic variables' selections
|
||||
* (so e.g. `pod` narrows to the chosen `namespace`).
|
||||
*/
|
||||
function DynamicSelector({
|
||||
variable,
|
||||
variables,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: DynamicSelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const existingQuery = useMemo(
|
||||
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
|
||||
[variables, selections, variable.name],
|
||||
);
|
||||
|
||||
const { data, isFetching } = useGetFieldValues({
|
||||
signal: signalForApi(variable.dynamicSignal),
|
||||
name: variable.dynamicAttribute,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
existingQuery: existingQuery || undefined,
|
||||
enabled: !!variable.dynamicAttribute,
|
||||
});
|
||||
|
||||
const options = useMemo(() => {
|
||||
const payload = data?.data;
|
||||
const values =
|
||||
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
|
||||
return sortValuesByOrder(values, variable.sort).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicSelector;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { isResolved, selectionToPayload } from '../selectionUtils';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface QuerySelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** Names this variable's query references; it waits until they're resolved. */
|
||||
parents: string[];
|
||||
/** All current selections, fed to the query as `{ name: value }`. */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-driven options. Dependency orchestration is declarative: the query is
|
||||
* `enabled` only once every parent is resolved, and the parent values are in the
|
||||
* query key — so it refetches automatically when a parent changes (and a cyclic
|
||||
* dependency is simply never enabled).
|
||||
*/
|
||||
function QuerySelector({
|
||||
variable,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: QuerySelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const payload = useMemo(() => selectionToPayload(selections), [selections]);
|
||||
const enabled = parents.every((parent) => isResolved(selections[parent]));
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
[
|
||||
'dashboard-variable',
|
||||
variable.name,
|
||||
variable.queryValue,
|
||||
payload,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
() =>
|
||||
dashboardVariablesQuery({
|
||||
query: variable.queryValue,
|
||||
variables: payload,
|
||||
}),
|
||||
{ enabled, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!data || data.statusCode !== 200 || !data.payload) {
|
||||
return [] as string[];
|
||||
}
|
||||
return sortValuesByOrder(
|
||||
data.payload.variableValues ?? [],
|
||||
variable.sort,
|
||||
).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuerySelector;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { InputRef } from 'antd';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
|
||||
import { Input } from 'antd';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import styles from '../VariablesBar.module.scss';
|
||||
|
||||
interface TextSelectorProps {
|
||||
selection: VariableSelection;
|
||||
/** Configured default; an emptied input falls back to it (V1 behaviour). */
|
||||
defaultValue?: string;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free-text variable input. Mirrors V1: edits are local and only committed on
|
||||
* blur / Enter (not per keystroke), and clearing the field restores the default.
|
||||
*/
|
||||
function TextSelector({
|
||||
selection,
|
||||
defaultValue,
|
||||
onChange,
|
||||
testId,
|
||||
}: TextSelectorProps): JSX.Element {
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const [value, setValue] = useState<string>(
|
||||
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
(next: string): void => onChange({ value: next, allSelected: false }),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const trimmed = event.target.value.trim();
|
||||
if (!trimmed && defaultValue) {
|
||||
setValue(defaultValue);
|
||||
commit(defaultValue);
|
||||
} else {
|
||||
commit(trimmed);
|
||||
}
|
||||
},
|
||||
[commit, defaultValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={styles.control}
|
||||
bordered={false}
|
||||
placeholder="Enter value"
|
||||
value={value}
|
||||
title={value}
|
||||
onChange={(e): void => setValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextSelector;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useMemo } from 'react';
|
||||
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import type { OptionData } from 'components/NewSelect/types';
|
||||
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import styles from '../VariablesBar.module.scss';
|
||||
|
||||
interface ValueSelectorProps {
|
||||
options: string[];
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
loading?: boolean;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the
|
||||
* shared NewSelect components, which provide search, the "ALL" option and
|
||||
* apply-on-close batching (so multi-select edits don't cascade per toggle).
|
||||
*/
|
||||
function ValueSelector({
|
||||
options,
|
||||
multiSelect,
|
||||
showAllOption,
|
||||
loading,
|
||||
selection,
|
||||
onChange,
|
||||
testId,
|
||||
}: ValueSelectorProps): JSX.Element {
|
||||
const optionData = useMemo<OptionData[]>(
|
||||
() => options.map((option) => ({ label: option, value: option })),
|
||||
[options],
|
||||
);
|
||||
|
||||
if (multiSelect) {
|
||||
const value = selection.allSelected
|
||||
? ALL_SELECT_VALUE
|
||||
: (Array.isArray(selection.value) ? selection.value : []).map(String);
|
||||
return (
|
||||
<CustomMultiSelect
|
||||
className={styles.control}
|
||||
data-testid={testId}
|
||||
options={optionData}
|
||||
value={value}
|
||||
loading={loading}
|
||||
showSearch
|
||||
placeholder="Select value"
|
||||
enableAllSelection={showAllOption}
|
||||
onChange={(next): void => {
|
||||
const values = Array.isArray(next)
|
||||
? next.map(String)
|
||||
: next
|
||||
? [String(next)]
|
||||
: [];
|
||||
if (values.length === 0) {
|
||||
onChange({ value: [], allSelected: false });
|
||||
return;
|
||||
}
|
||||
// CustomMultiSelect emits the full value set when ALL is picked.
|
||||
const isAll =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
options.every((option) => values.includes(option));
|
||||
onChange({ value: values, allSelected: isAll });
|
||||
}}
|
||||
onClear={(): void => onChange({ value: [], allSelected: false })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
className={styles.select}
|
||||
data-testid={testId}
|
||||
options={optionData}
|
||||
value={
|
||||
selection.value == null || Array.isArray(selection.value)
|
||||
? undefined
|
||||
: String(selection.value)
|
||||
}
|
||||
loading={loading}
|
||||
showSearch
|
||||
placeholder="Select value"
|
||||
onChange={(next): void =>
|
||||
onChange({ value: next == null ? '' : String(next), allSelected: false })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValueSelector;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelection } from './selectionTypes';
|
||||
|
||||
/**
|
||||
* When fetched options arrive and the current selection isn't one of them,
|
||||
* auto-pick the variable's default (if present in the options) or the first
|
||||
* option — so dependent children always have a usable parent value.
|
||||
*/
|
||||
export function useAutoSelect(
|
||||
variable: VariableFormModel,
|
||||
options: string[],
|
||||
selection: VariableSelection,
|
||||
onChange: (selection: VariableSelection) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (options.length === 0 || selection.allSelected) {
|
||||
return;
|
||||
}
|
||||
const current = selection.value;
|
||||
const isValid = Array.isArray(current)
|
||||
? current.length > 0 && current.every((c) => options.includes(String(c)))
|
||||
: current !== '' &&
|
||||
current !== null &&
|
||||
current !== undefined &&
|
||||
options.includes(String(current));
|
||||
if (isValid) {
|
||||
return;
|
||||
}
|
||||
const fallback = (variable.defaultValue as { value?: string } | undefined)
|
||||
?.value;
|
||||
const initial =
|
||||
fallback && options.includes(fallback) ? fallback : options[0];
|
||||
onChange({
|
||||
value: variable.multiSelect ? [initial] : initial,
|
||||
allSelected: false,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options]);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { parseAsJson, useQueryState } from 'nuqs';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
import {
|
||||
computeVariableDependencies,
|
||||
type VariableDependencyData,
|
||||
} from './variableDependencies';
|
||||
|
||||
/** URL sentinel for an "ALL values selected" state (matches V1). */
|
||||
export const ALL_SELECTED = '__ALL__';
|
||||
|
||||
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
|
||||
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
|
||||
(v) =>
|
||||
typeof v === 'object' && v !== null
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
);
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
const def = (
|
||||
model.defaultValue as { value?: SelectedVariableValue } | undefined
|
||||
)?.value;
|
||||
if (def !== undefined && def !== null && def !== '') {
|
||||
return { value: def, allSelected: false };
|
||||
}
|
||||
return { value: model.multiSelect ? [] : '', allSelected: false };
|
||||
}
|
||||
|
||||
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
|
||||
return raw === ALL_SELECTED
|
||||
? { value: null, allSelected: true }
|
||||
: { value: raw, allSelected: false };
|
||||
}
|
||||
|
||||
interface UseVariableSelection {
|
||||
variables: VariableFormModel[];
|
||||
dependencyData: VariableDependencyData;
|
||||
selection: VariableSelectionMap;
|
||||
setSelection: (name: string, selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime variable selection: derives the variable list from the spec, seeds
|
||||
* each value from URL → localStorage(store) → default, and persists changes to
|
||||
* both the store and the URL. Never writes to the dashboard spec.
|
||||
*/
|
||||
export function useVariableSelection(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
): UseVariableSelection {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
|
||||
const variables = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const dependencyData = useMemo(
|
||||
() => computeVariableDependencies(variables),
|
||||
[variables],
|
||||
);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
|
||||
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
|
||||
|
||||
const [urlValues, setUrlValues] = useQueryState(
|
||||
'variables',
|
||||
variablesUrlParser.withOptions({ history: 'replace' }),
|
||||
);
|
||||
|
||||
// Seed selections for this dashboard: URL wins, then persisted store, then default.
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
// `selection` here is the persisted (localStorage) map on mount — the
|
||||
// effect deliberately doesn't depend on it, so seeding runs once per set.
|
||||
const stored = selection;
|
||||
const seeded: VariableSelectionMap = {};
|
||||
variables.forEach((variable) => {
|
||||
const urlValue = urlValues?.[variable.name];
|
||||
if (urlValue !== undefined) {
|
||||
seeded[variable.name] = fromUrlValue(urlValue);
|
||||
} else if (stored[variable.name]) {
|
||||
seeded[variable.name] = stored[variable.name];
|
||||
} else {
|
||||
seeded[variable.name] = defaultSelection(variable);
|
||||
}
|
||||
});
|
||||
setVariableValues(dashboardId, seeded);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, variables]);
|
||||
|
||||
const setSelection = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
setVariableValue(dashboardId, name, next);
|
||||
void setUrlValues((prev) => ({
|
||||
...(prev ?? {}),
|
||||
[name]: next.allSelected ? ALL_SELECTED : next.value,
|
||||
}));
|
||||
},
|
||||
[dashboardId, setVariableValue, setUrlValues],
|
||||
);
|
||||
|
||||
return { variables, dependencyData, selection, setSelection };
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
|
||||
/**
|
||||
* Inter-variable dependency graph for runtime selection. A QUERY variable
|
||||
* "depends on" another variable when its query text references that variable
|
||||
* (`{{.name}}`, `{{name}}`, `$name`, `[[name]]`). When a variable's value
|
||||
* changes, its dependent QUERY variables must refetch. Ported from the V1
|
||||
* dashboard-variables runtime; operates on the V2 flat variable model.
|
||||
*/
|
||||
|
||||
export type VariableGraph = Record<string, string[]>;
|
||||
|
||||
export interface VariableDependencyData {
|
||||
/** Topological order of variables (parents before children). */
|
||||
order: string[];
|
||||
/** Direct children (dependents) of each variable. */
|
||||
graph: VariableGraph;
|
||||
/** Direct parents of each variable. */
|
||||
parentGraph: VariableGraph;
|
||||
/** All transitive descendants of each variable (precomputed). */
|
||||
transitiveDescendants: VariableGraph;
|
||||
hasCycle: boolean;
|
||||
cycleNodes?: string[];
|
||||
}
|
||||
|
||||
/** Names of QUERY variables whose query references `variableName`. */
|
||||
function getDependents(
|
||||
variableName: string,
|
||||
variables: VariableFormModel[],
|
||||
): string[] {
|
||||
return variables
|
||||
.filter(
|
||||
(v) =>
|
||||
v.type === 'QUERY' &&
|
||||
!!v.name &&
|
||||
textContainsVariableReference(v.queryValue || '', variableName),
|
||||
)
|
||||
.map((v) => v.name);
|
||||
}
|
||||
|
||||
/** variable name → its direct dependents (children). */
|
||||
export function buildDependencies(
|
||||
variables: VariableFormModel[],
|
||||
): VariableGraph {
|
||||
const graph: VariableGraph = {};
|
||||
variables.forEach((v) => {
|
||||
if (v.name) {
|
||||
graph[v.name] = getDependents(v.name, variables);
|
||||
}
|
||||
});
|
||||
return graph;
|
||||
}
|
||||
|
||||
/** Invert a child graph into a parent graph. */
|
||||
export function buildParentGraph(graph: VariableGraph): VariableGraph {
|
||||
const parents: VariableGraph = {};
|
||||
Object.keys(graph).forEach((node) => {
|
||||
parents[node] = parents[node] ?? [];
|
||||
});
|
||||
Object.entries(graph).forEach(([node, children]) => {
|
||||
children.forEach((child) => {
|
||||
parents[child] = parents[child] ?? [];
|
||||
parents[child].push(node);
|
||||
});
|
||||
});
|
||||
return parents;
|
||||
}
|
||||
|
||||
function collectCyclePath(
|
||||
graph: VariableGraph,
|
||||
start: string,
|
||||
end: string,
|
||||
): string[] {
|
||||
const path: string[] = [];
|
||||
let current = start;
|
||||
const findParent = (node: string): string | undefined =>
|
||||
Object.keys(graph).find((key) => graph[key]?.includes(node));
|
||||
while (current !== end) {
|
||||
const parent = findParent(current);
|
||||
if (!parent) {
|
||||
break;
|
||||
}
|
||||
path.push(parent);
|
||||
current = parent;
|
||||
}
|
||||
return [start, ...path];
|
||||
}
|
||||
|
||||
function detectCycle(
|
||||
graph: VariableGraph,
|
||||
node: string,
|
||||
visited: Set<string>,
|
||||
recStack: Set<string>,
|
||||
): string[] | null {
|
||||
if (!visited.has(node)) {
|
||||
visited.add(node);
|
||||
recStack.add(node);
|
||||
let cycleNodes: string[] | null = null;
|
||||
(graph[node] || []).some((neighbor) => {
|
||||
if (!visited.has(neighbor)) {
|
||||
const found = detectCycle(graph, neighbor, visited, recStack);
|
||||
if (found) {
|
||||
cycleNodes = found;
|
||||
return true;
|
||||
}
|
||||
} else if (recStack.has(neighbor)) {
|
||||
cycleNodes = collectCyclePath(graph, node, neighbor);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (cycleNodes) {
|
||||
return cycleNodes;
|
||||
}
|
||||
}
|
||||
recStack.delete(node);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build the full dependency data (topo order, parents, transitive descendants, cycle info). */
|
||||
export function buildDependencyData(
|
||||
dependencies: VariableGraph,
|
||||
): VariableDependencyData {
|
||||
const inDegree: Record<string, number> = {};
|
||||
const adjList: VariableGraph = {};
|
||||
|
||||
Object.keys(dependencies).forEach((node) => {
|
||||
inDegree[node] = inDegree[node] ?? 0;
|
||||
adjList[node] = adjList[node] ?? [];
|
||||
(dependencies[node] || []).forEach((child) => {
|
||||
inDegree[child] = inDegree[child] ?? 0;
|
||||
inDegree[child] += 1;
|
||||
adjList[node].push(child);
|
||||
});
|
||||
});
|
||||
|
||||
const visited = new Set<string>();
|
||||
const recStack = new Set<string>();
|
||||
let cycleNodes: string[] | undefined;
|
||||
Object.keys(dependencies).some((node) => {
|
||||
if (!visited.has(node)) {
|
||||
const found = detectCycle(dependencies, node, visited, recStack);
|
||||
if (found) {
|
||||
cycleNodes = found;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Topological sort (Kahn's algorithm).
|
||||
const queue = Object.keys(inDegree).filter((n) => inDegree[n] === 0);
|
||||
const order: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (current === undefined) {
|
||||
break;
|
||||
}
|
||||
order.push(current);
|
||||
(adjList[current] || []).forEach((neighbor) => {
|
||||
inDegree[neighbor] -= 1;
|
||||
if (inDegree[neighbor] === 0) {
|
||||
queue.push(neighbor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const hasCycle = order.length !== Object.keys(dependencies).length;
|
||||
|
||||
// Transitive descendants: walk topo order in reverse.
|
||||
const transitiveDescendants: VariableGraph = {};
|
||||
for (let i = order.length - 1; i >= 0; i--) {
|
||||
const node = order[i];
|
||||
const desc = new Set<string>();
|
||||
(adjList[node] || []).forEach((child) => {
|
||||
desc.add(child);
|
||||
(transitiveDescendants[child] || []).forEach((d) => desc.add(d));
|
||||
});
|
||||
transitiveDescendants[node] = Array.from(desc);
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
graph: adjList,
|
||||
parentGraph: buildParentGraph(adjList),
|
||||
transitiveDescendants,
|
||||
hasCycle,
|
||||
cycleNodes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute the full dependency data straight from the variable list. */
|
||||
export function computeVariableDependencies(
|
||||
variables: VariableFormModel[],
|
||||
): VariableDependencyData {
|
||||
return buildDependencyData(buildDependencies(variables));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../../VariablesBar/selectionTypes';
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
|
||||
/**
|
||||
* Runtime variable selection — the values the user picks in the variable bar.
|
||||
* Keyed by dashboardId → variable name. Frontend-only and persisted to
|
||||
* localStorage (mirrored to the URL by the bar for shareable links); it is
|
||||
* deliberately NOT part of the dashboard spec, so selecting a value never
|
||||
* patches the dashboard.
|
||||
*/
|
||||
export interface VariableSelectionSlice {
|
||||
variableValues: Record<string, VariableSelectionMap>;
|
||||
setVariableValue: (
|
||||
dashboardId: string,
|
||||
name: string,
|
||||
selection: VariableSelection,
|
||||
) => void;
|
||||
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
|
||||
setVariableValues: (dashboardId: string, values: VariableSelectionMap) => void;
|
||||
}
|
||||
|
||||
export const createVariableSelectionSlice: StateCreator<
|
||||
DashboardStore,
|
||||
[['zustand/persist', unknown]],
|
||||
[],
|
||||
VariableSelectionSlice
|
||||
> = (set, get) => ({
|
||||
variableValues: {},
|
||||
setVariableValue: (dashboardId, name, selection): void => {
|
||||
const { variableValues } = get();
|
||||
set({
|
||||
variableValues: {
|
||||
...variableValues,
|
||||
[dashboardId]: { ...variableValues[dashboardId], [name]: selection },
|
||||
},
|
||||
});
|
||||
},
|
||||
setVariableValues: (dashboardId, values): void => {
|
||||
const { variableValues } = get();
|
||||
set({
|
||||
variableValues: { ...variableValues, [dashboardId]: values },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Selector: the selection map for a dashboard (empty if none). */
|
||||
export const selectVariableValues =
|
||||
(dashboardId: string) =>
|
||||
(state: DashboardStore): VariableSelectionMap =>
|
||||
state.variableValues[dashboardId] ?? {};
|
||||
@@ -9,25 +9,36 @@ import {
|
||||
createCollapseSlice,
|
||||
type CollapseSlice,
|
||||
} from './slices/collapseSlice';
|
||||
import {
|
||||
createVariableSelectionSlice,
|
||||
type VariableSelectionSlice,
|
||||
} from './slices/variableSelectionSlice';
|
||||
|
||||
export type DashboardStore = EditContextSlice & CollapseSlice;
|
||||
export type DashboardStore = EditContextSlice &
|
||||
CollapseSlice &
|
||||
VariableSelectionSlice;
|
||||
|
||||
/**
|
||||
* V2 dashboard session store. Holds cross-cutting client state only — never the
|
||||
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
|
||||
* dashboard spec (that stays in react-query via useGetDashboardV2). Slices:
|
||||
* - edit-context: dashboardId / isEditable / refetch (set once, not persisted).
|
||||
* - collapse: per-section open state (frontend-only, persisted to localStorage).
|
||||
* - variable-selection: runtime variable values (frontend-only, persisted).
|
||||
*/
|
||||
export const useDashboardStore = create<DashboardStore>()(
|
||||
persist(
|
||||
(...a) => ({
|
||||
...createEditContextSlice(...a),
|
||||
...createCollapseSlice(...a),
|
||||
...createVariableSelectionSlice(...a),
|
||||
}),
|
||||
{
|
||||
name: '@signoz/dashboard-v2',
|
||||
// Persist only the collapse map — context (incl. the refetch fn) is transient.
|
||||
partialize: (state) => ({ collapsed: state.collapsed }),
|
||||
// Persist UI-only state (context incl. the refetch fn is transient).
|
||||
partialize: (state) => ({
|
||||
collapsed: state.collapsed,
|
||||
variableValues: state.variableValues,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -69,24 +69,19 @@ function stripUndefinedLabels(
|
||||
export function toPostableRuleDTO(
|
||||
local: PostableAlertRuleV2,
|
||||
): RuletypesPostableRuleDTO {
|
||||
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
|
||||
const payload = {
|
||||
alert: local.alert,
|
||||
alertType: toAlertTypeDTO(local.alertType),
|
||||
ruleType: toRuleTypeDTO(local.ruleType),
|
||||
condition: local.condition,
|
||||
annotations: local.annotations,
|
||||
labels: stripUndefinedLabels(local.labels),
|
||||
evalWindow: (local as unknown as RuletypesPostableRuleDTO).evalWindow,
|
||||
frequency: (local as unknown as RuletypesPostableRuleDTO).frequency,
|
||||
preferredChannels: (local as unknown as RuletypesPostableRuleDTO)
|
||||
.preferredChannels,
|
||||
notificationSettings: local.notificationSettings,
|
||||
evaluation: local.evaluation,
|
||||
schemaVersion: local.schemaVersion,
|
||||
source: local.source,
|
||||
version: local.version,
|
||||
disabled: local.disabled,
|
||||
description: (local as unknown as RuletypesPostableRuleDTO).description,
|
||||
};
|
||||
return payload as unknown as RuletypesPostableRuleDTO;
|
||||
}
|
||||
@@ -94,7 +89,7 @@ export function toPostableRuleDTO(
|
||||
export function toPostableRuleDTOFromAlertDef(
|
||||
local: AlertDef,
|
||||
): RuletypesPostableRuleDTO {
|
||||
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
|
||||
const payload = {
|
||||
alert: local.alert,
|
||||
alertType: toAlertTypeDTO(local.alertType),
|
||||
ruleType: toRuleTypeDTO(local.ruleType),
|
||||
@@ -104,16 +99,11 @@ export function toPostableRuleDTOFromAlertDef(
|
||||
evalWindow: local.evalWindow,
|
||||
frequency: local.frequency,
|
||||
preferredChannels: local.preferredChannels,
|
||||
notificationSettings: (local as unknown as RuletypesPostableRuleDTO)
|
||||
.notificationSettings,
|
||||
evaluation: (local as unknown as RuletypesPostableRuleDTO).evaluation,
|
||||
schemaVersion: (local as unknown as RuletypesPostableRuleDTO).schemaVersion,
|
||||
source: local.source,
|
||||
version: local.version,
|
||||
disabled: local.disabled,
|
||||
description: (local as unknown as RuletypesPostableRuleDTO).description,
|
||||
};
|
||||
return payload;
|
||||
return payload as unknown as RuletypesPostableRuleDTO;
|
||||
}
|
||||
|
||||
export function fromRuleDTOToPostableRuleV2(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -119,7 +119,11 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
queries := make(map[string]qbtypes.Query)
|
||||
steps := make(map[string]qbtypes.Step)
|
||||
|
||||
missingMetricQueries, metricWarnings, err := q.resolveMetricMetadata(ctx, req.CompositeQuery.Queries, req.Start, req.End)
|
||||
// Resolve metric metadata once per request: patches each metric-aggregation
|
||||
// query's spec in place, returns the queries whose every aggregation was
|
||||
// missing (used for preseeded empty results), and any dormant-metric
|
||||
// warning string. NotFound errors for never-seen metrics are propagated.
|
||||
missingMetricQueries, dormantMetricsWarningMsg, err := q.resolveMetricMetadata(ctx, req.CompositeQuery.Queries, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -236,15 +240,13 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(metricWarnings) > 0 {
|
||||
if dormantMetricsWarningMsg != "" {
|
||||
if qbResp.Warning == nil {
|
||||
qbResp.Warning = &qbtypes.QueryWarnData{}
|
||||
}
|
||||
for _, w := range metricWarnings {
|
||||
qbResp.Warning.Warnings = append(qbResp.Warning.Warnings, qbtypes.QueryWarnDataAdditional{
|
||||
Message: w,
|
||||
})
|
||||
}
|
||||
qbResp.Warning.Warnings = append(qbResp.Warning.Warnings, qbtypes.QueryWarnDataAdditional{
|
||||
Message: dormantMetricsWarningMsg,
|
||||
})
|
||||
}
|
||||
}
|
||||
return qbResp, qbErr
|
||||
@@ -300,11 +302,12 @@ func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.Quer
|
||||
// - missingMetricQueries: names of queries whose every aggregation was
|
||||
// missing. Used downstream to preseed empty result placeholders so the
|
||||
// response still has an entry per requested query name.
|
||||
// - metricWarnings: human-readable warnings for metrics that could not be
|
||||
// resolved: never-seen metrics and dormant metrics (seen but no data in
|
||||
// the query window).
|
||||
// - err: Internal when a metadata fetch fails.
|
||||
func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.QueryEnvelope, start, end uint64) (missingMetricQueries []string, metricWarnings []string, err error) {
|
||||
// - dormantWarning: a human-readable warning describing metrics that exist in
|
||||
// the store but produced no data within the query window. Empty when no
|
||||
// such metrics are present.
|
||||
// - err: NotFound when one or more referenced metrics have never been seen,
|
||||
// or Internal when a metadata fetch fails.
|
||||
func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.QueryEnvelope, start, end uint64) (missingMetricQueries []string, dormantWarning string, err error) {
|
||||
metricNames := make([]string, 0)
|
||||
for idx := range queries {
|
||||
if queries[idx].Type != qbtypes.QueryTypeBuilder {
|
||||
@@ -322,13 +325,13 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
|
||||
if len(metricNames) == 0 {
|
||||
return nil, nil, nil
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
metricTemporality, metricTypes, err := q.metadataStore.FetchTemporalityAndTypeMulti(ctx, start, end, metricNames...)
|
||||
if err != nil {
|
||||
q.logger.WarnContext(ctx, "failed to fetch metric temporality", errors.Attr(err), slog.Any("metrics", metricNames))
|
||||
return nil, nil, errors.NewInternalf(errors.CodeInternal, "failed to fetch metrics temporality")
|
||||
return nil, "", errors.NewInternalf(errors.CodeInternal, "failed to fetch metrics temporality")
|
||||
}
|
||||
q.logger.DebugContext(ctx, "fetched metric temporalities and types", slog.Any("metric_temporality", metricTemporality), slog.Any("metric_types", metricTypes))
|
||||
|
||||
@@ -360,7 +363,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
// Type is resolved now; validate aggregation compatibility against it.
|
||||
if err := spec.Aggregations[i].ValidateForType(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
presentAggregations = append(presentAggregations, spec.Aggregations[i])
|
||||
}
|
||||
@@ -373,7 +376,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
|
||||
if len(missingMetrics) == 0 {
|
||||
return missingMetricQueries, nil, nil
|
||||
return missingMetricQueries, "", nil
|
||||
}
|
||||
|
||||
isInternalMetric := func(n string) bool { return strings.HasPrefix(n, "signoz.") || strings.HasPrefix(n, "signoz_") }
|
||||
@@ -384,33 +387,29 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
}
|
||||
if len(externalMissingMetrics) == 0 {
|
||||
return missingMetricQueries, nil, nil
|
||||
// this means all missing metrics are internal, and since internal metrics
|
||||
// aren't user-controlled, skip errors/warnings for them since users can't act on them
|
||||
return missingMetricQueries, "", nil
|
||||
}
|
||||
|
||||
// Classify each missing metric: never-seen -> warning with empty result;
|
||||
// seen-but-no-data-in-window -> dormant warning.
|
||||
// Classify each missing metric: never-seen → NotFound error; seen-but-no-
|
||||
// data-in-window → dormant warning.
|
||||
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, externalMissingMetrics...)
|
||||
var nonExistentMetrics []string
|
||||
var dormantMetrics []string
|
||||
nonExistentMetrics := []string{}
|
||||
for _, name := range externalMissingMetrics {
|
||||
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
|
||||
dormantMetrics = append(dormantMetrics, name)
|
||||
continue
|
||||
}
|
||||
nonExistentMetrics = append(nonExistentMetrics, name)
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
|
||||
// Never-seen metrics: the query already gets a preseeded empty result
|
||||
// via the aggregation-dropping path above; we just attach a warning.
|
||||
if len(nonExistentMetrics) == 1 {
|
||||
warnings = append(warnings, fmt.Sprintf("metric %s has never been received. Check the metric name and instrumentation", nonExistentMetrics[0]))
|
||||
} else if len(nonExistentMetrics) > 1 {
|
||||
warnings = append(warnings, fmt.Sprintf("the following metrics have never been received. Check the metric names and instrumentation: %s", strings.Join(nonExistentMetrics, ", ")))
|
||||
return nil, "", errors.NewNotFoundf(errors.CodeNotFound, "could not find the metric %s", nonExistentMetrics[0])
|
||||
}
|
||||
if len(nonExistentMetrics) > 1 {
|
||||
return nil, "", errors.NewNotFoundf(errors.CodeNotFound, "the following metrics were not found: %s", strings.Join(nonExistentMetrics, ", "))
|
||||
}
|
||||
|
||||
// Dormant metrics: seen before but no data in the query window.
|
||||
// All missing metrics are dormant — assemble the warning string.
|
||||
lastSeenStr := func(name string) string {
|
||||
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
|
||||
ago := humanize.RelTime(time.UnixMilli(ts), time.Now(), "ago", "from now")
|
||||
@@ -418,16 +417,16 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
return name
|
||||
}
|
||||
if len(dormantMetrics) == 1 {
|
||||
warnings = append(warnings, fmt.Sprintf("no data found for the metric %s in the query time range", lastSeenStr(dormantMetrics[0])))
|
||||
} else if len(dormantMetrics) > 1 {
|
||||
parts := make([]string, len(dormantMetrics))
|
||||
for i, m := range dormantMetrics {
|
||||
if len(externalMissingMetrics) == 1 {
|
||||
dormantWarning = fmt.Sprintf("no data found for the metric %s in the query time range", lastSeenStr(missingMetrics[0]))
|
||||
} else {
|
||||
parts := make([]string, len(externalMissingMetrics))
|
||||
for i, m := range externalMissingMetrics {
|
||||
parts[i] = lastSeenStr(m)
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", ")))
|
||||
dormantWarning = fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", "))
|
||||
}
|
||||
return missingMetricQueries, warnings, nil
|
||||
return missingMetricQueries, dormantWarning, nil
|
||||
}
|
||||
|
||||
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
|
||||
|
||||
@@ -37,7 +37,7 @@ func (m *mockMetricStmtBuilder) Build(_ context.Context, _, _ uint64, _ qbtypes.
|
||||
|
||||
func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
// When a metric has UnspecifiedType and is not found in the metadata store,
|
||||
// the querier should return an empty result with a warning instead of an error.
|
||||
// the querier should return a not-found error, even if the request provides a temporality
|
||||
providerSettings := instrumentationtest.New().ToProviderSettings()
|
||||
metadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
|
||||
@@ -80,14 +80,9 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := q.QueryRange(context.Background(), valuer.GenerateUUID(), req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, resp.Warning)
|
||||
|
||||
require.Len(t, resp.Warning.Warnings, 1)
|
||||
assert.Contains(t, resp.Warning.Warnings[0].Message, "unknown_metric")
|
||||
assert.Contains(t, resp.Warning.Warnings[0].Message, "has never been received")
|
||||
_, err := q.QueryRange(context.Background(), valuer.GenerateUUID(), req)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "could not find the metric unknown_metric")
|
||||
}
|
||||
|
||||
func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
|
||||
@@ -101,29 +101,9 @@ func (b *MetricQueryStatementBuilder) Build(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pairFallbackWarnings []string
|
||||
for _, sel := range keySelectors {
|
||||
if _, ok := keys[sel.Name]; !ok {
|
||||
keys[sel.Name] = []*telemetrytypes.TelemetryFieldKey{{
|
||||
Name: sel.Name,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
}}
|
||||
pairFallbackWarnings = append(pairFallbackWarnings,
|
||||
fmt.Sprintf("key `%s` not found on metric %s", sel.Name, query.Aggregations[0].MetricName),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
start, end = querybuilder.AdjustedMetricTimeRange(start, end, uint64(query.StepInterval.Seconds()), query)
|
||||
|
||||
stmt, err := b.buildPipelineStatement(ctx, start, end, query, keys, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stmt.Warnings = append(stmt.Warnings, pairFallbackWarnings...)
|
||||
return stmt, nil
|
||||
return b.buildPipelineStatement(ctx, start, end, query, keys, variables)
|
||||
}
|
||||
|
||||
func (b *MetricQueryStatementBuilder) buildPipelineStatement(
|
||||
|
||||
@@ -217,39 +217,6 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_missing_key_falls_back_to_labels",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_calls_total",
|
||||
Type: metrictypes.SumType,
|
||||
Temporality: metrictypes.Cumulative,
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "k8s.statefulset.name = 'my-statefulset'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "k8s.statefulset.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `k8s.statefulset.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `k8s.statefulset.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'k8s.statefulset.name') AS `k8s.statefulset.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? AND JSONExtractString(labels, 'k8s.statefulset.name') = ? GROUP BY fingerprint, `k8s.statefulset.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `k8s.statefulset.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `k8s.statefulset.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `k8s.statefulset.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `k8s.statefulset.name`, ts",
|
||||
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", false, "my-statefulset", "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
|
||||
Warnings: []string{"key `k8s.statefulset.name` not found on metric signoz_calls_total"},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -614,7 +614,7 @@ def test_histogram_p90_returns_warning_outside_data_window(
|
||||
assert warnings[0]["message"].startswith(f"no data found for the metric {metric_name}")
|
||||
|
||||
|
||||
def test_non_existent_metrics_returns_warning(
|
||||
def test_non_existent_metrics_returns_404(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
@@ -635,11 +635,9 @@ def test_non_existent_metrics_returns_warning(
|
||||
|
||||
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
|
||||
response = make_query_request(signoz, token, start_2h, end_ms, [query])
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
data = response.json()
|
||||
warnings = get_all_warnings(data)
|
||||
assert any("whatevergoennnsgoeshere" in w["message"] and "has never been received" in w["message"] for w in warnings), f"expected never-seen metric warning, got: {warnings}"
|
||||
assert get_error_message(response.json()) == "could not find the metric whatevergoennnsgoeshere"
|
||||
|
||||
|
||||
def test_non_existent_internal_metrics_returns_no_warning(
|
||||
|
||||
Reference in New Issue
Block a user