Compare commits

..

1 Commits

Author SHA1 Message Date
Abhi Kumar
38d516cbbe fix: added fix for traceoperator not getting saved in alerts 2026-05-05 12:18:14 +05:30
208 changed files with 2894 additions and 5476 deletions

1
.github/CODEOWNERS vendored
View File

@@ -107,7 +107,6 @@ go.mod @therealpandey
/pkg/modules/organization/ @therealpandey
/pkg/modules/authdomain/ @therealpandey
/pkg/modules/role/ @therealpandey
/pkg/types/coretypes/ @therealpandey @vikrantgupta25
# IdentN Owners

View File

@@ -102,20 +102,3 @@ jobs:
run: |
go run cmd/enterprise/*.go generate openapi
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)
authz:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: generate-authz
run: |
go run cmd/enterprise/*.go generate authz
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in authz permissions. Run go run cmd/enterprise/*.go generate authz locally and commit."; exit 1)

View File

@@ -63,6 +63,46 @@ jobs:
uses: actions/checkout@v4
- name: run
run: bash frontend/scripts/validate-md-languages.sh
authz:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v5
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: deps-install
working-directory: ./frontend
run: |
yarn install
- name: uv-install
uses: astral-sh/setup-uv@v5
- name: uv-deps
working-directory: ./tests/integration
run: |
uv sync
- name: setup-test
run: |
make py-test-setup
- name: generate
working-directory: ./frontend
run: |
yarn generate:permissions-type
- name: teardown-test
if: always()
run: |
make py-test-teardown
- name: validate
run: |
if ! git diff --exit-code frontend/src/hooks/useAuthZ/permissions.type.ts; then
echo "::error::frontend/src/hooks/useAuthZ/permissions.type.ts is out of date. Please run the generator locally and commit the changes: npm run generate:permissions-type (from the frontend directory)"
exit 1
fi
openapi:
if: |
github.event_name == 'merge_group' ||

View File

@@ -1,117 +0,0 @@
package cmd
import (
"bytes"
"context"
"os"
"sort"
"text/template"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/spf13/cobra"
)
const permissionsTypePath = "frontend/src/hooks/useAuthZ/permissions.type.ts"
var permissionsTypeTemplate = template.Must(template.New("permissions").Parse(
`// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY cmd/enterprise/*.go generate authz
export default {
status: 'success',
data: {
resources: [
{{- range .Resources }}
{
kind: '{{ .Kind }}',
type: '{{ .Type }}',
},
{{- end }}
],
relations: {
{{- range .Relations }}
{{ .Verb }}: [{{ range $i, $t := .Types }}{{ if $i }}, {{ end }}'{{ $t }}'{{ end }}],
{{- end }}
},
},
} as const;
`))
type permissionsTypeRelation struct {
Verb string
Types []string
}
type permissionsTypeResource struct {
Kind string
Type string
}
type permissionsTypeData struct {
Resources []permissionsTypeResource
Relations []permissionsTypeRelation
}
func registerGenerateAuthz(parentCmd *cobra.Command) {
authzCmd := &cobra.Command{
Use: "authz",
Short: "Generate authz permissions for the frontend",
RunE: func(currCmd *cobra.Command, args []string) error {
return runGenerateAuthz(currCmd.Context())
},
}
parentCmd.AddCommand(authzCmd)
}
func runGenerateAuthz(_ context.Context) error {
registry := coretypes.NewRegistry()
allowedResources := map[string]bool{
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourcesRole).String(): true,
}
allowedTypes := map[string]bool{}
refs := registry.ResourceRefs()
resources := make([]permissionsTypeResource, 0, len(refs))
for _, ref := range refs {
if !allowedResources[ref.String()] {
continue
}
allowedTypes[ref.Type.StringValue()] = true
resources = append(resources, permissionsTypeResource{
Kind: ref.Kind.String(),
Type: ref.Type.StringValue(),
})
}
typesByVerb := registry.TypesByVerb()
verbs := make([]coretypes.Verb, 0, len(typesByVerb))
for verb := range typesByVerb {
verbs = append(verbs, verb)
}
sort.Slice(verbs, func(i, j int) bool { return verbs[i].StringValue() < verbs[j].StringValue() })
relations := make([]permissionsTypeRelation, 0, len(verbs))
for _, verb := range verbs {
types := make([]string, 0, len(typesByVerb[verb]))
for _, t := range typesByVerb[verb] {
if !allowedTypes[t.StringValue()] {
continue
}
types = append(types, t.StringValue())
}
relations = append(relations, permissionsTypeRelation{
Verb: verb.StringValue(),
Types: types,
})
}
var buf bytes.Buffer
if err := permissionsTypeTemplate.Execute(&buf, permissionsTypeData{Resources: resources, Relations: relations}); err != nil {
return err
}
return os.WriteFile(permissionsTypePath, buf.Bytes(), 0o600)
}

View File

@@ -92,13 +92,13 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, config authz.Config, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore, config)
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete, _ dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
if err != nil {
return nil, err
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, authtypes.NewRegistry()), nil
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)

View File

@@ -137,12 +137,12 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return authNs, nil
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, config authz.Config, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore, config)
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, dashboardModule dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
if err != nil {
return nil, err
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, authtypes.NewRegistry()), nil
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, dashboardModule), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)

View File

@@ -16,7 +16,6 @@ func RegisterGenerate(parentCmd *cobra.Command, logger *slog.Logger) {
}
registerGenerateOpenAPI(generateCmd)
registerGenerateAuthz(generateCmd)
parentCmd.AddCommand(generateCmd)
}

View File

@@ -428,4 +428,4 @@ authz:
provider: openfga
openfga:
# maximum tuples allowed per openfga write operation.
max_tuples_per_write: 300
max_tuples_per_write: 100

View File

@@ -301,26 +301,69 @@ components:
type: string
type: object
AuthtypesGettableAuthDomain:
oneOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
authNProviderInfo:
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
createdAt:
format: date-time
type: string
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
id:
type: string
name:
type: string
oidcConfig:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
orgId:
type: string
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
samlConfig:
$ref: '#/components/schemas/AuthtypesSamlConfig'
ssoEnabled:
type: boolean
ssoType:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
updatedAt:
format: date-time
type: string
required:
- id
type: object
AuthtypesGettableObjects:
properties:
resource:
$ref: '#/components/schemas/AuthtypesResource'
selectors:
items:
type: string
type: array
required:
- resource
- selectors
type: object
AuthtypesGettableResources:
properties:
relations:
additionalProperties:
items:
type: string
type: array
nullable: true
type: object
resources:
items:
$ref: '#/components/schemas/AuthtypesResource'
type: array
required:
- resources
- relations
type: object
AuthtypesGettableToken:
properties:
accessToken:
@@ -337,9 +380,9 @@ components:
authorized:
type: boolean
object:
$ref: '#/components/schemas/CoretypesObject'
$ref: '#/components/schemas/AuthtypesObject'
relation:
$ref: '#/components/schemas/AuthtypesRelation'
type: string
required:
- relation
- object
@@ -387,6 +430,16 @@ components:
issuerAlias:
type: string
type: object
AuthtypesObject:
properties:
resource:
$ref: '#/components/schemas/AuthtypesResource'
selector:
type: string
required:
- resource
- selector
type: object
AuthtypesOrgSessionContext:
properties:
authNSupport:
@@ -403,6 +456,22 @@ components:
provider:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesPatchableObjects:
properties:
additions:
items:
$ref: '#/components/schemas/AuthtypesGettableObjects'
nullable: true
type: array
deletions:
items:
$ref: '#/components/schemas/AuthtypesGettableObjects'
nullable: true
type: array
required:
- additions
- deletions
type: object
AuthtypesPatchableRole:
properties:
description:
@@ -440,15 +509,16 @@ components:
refreshToken:
type: string
type: object
AuthtypesRelation:
enum:
- create
- read
- update
- delete
- list
- assignee
type: string
AuthtypesResource:
properties:
name:
type: string
type:
type: string
required:
- name
- type
type: object
AuthtypesRole:
properties:
createdAt:
@@ -512,14 +582,14 @@ components:
AuthtypesTransaction:
properties:
object:
$ref: '#/components/schemas/CoretypesObject'
$ref: '#/components/schemas/AuthtypesObject'
relation:
$ref: '#/components/schemas/AuthtypesRelation'
type: string
required:
- relation
- object
type: object
AuthtypesUpdatableAuthDomain:
AuthtypesUpdateableAuthDomain:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
@@ -2149,64 +2219,6 @@ components:
to_user:
type: string
type: object
CoretypesObject:
properties:
resource:
$ref: '#/components/schemas/CoretypesResourceRef'
selector:
type: string
required:
- resource
- selector
type: object
CoretypesObjectGroup:
properties:
resource:
$ref: '#/components/schemas/CoretypesResourceRef'
selectors:
items:
type: string
type: array
required:
- resource
- selectors
type: object
CoretypesPatchableObjects:
properties:
additions:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
nullable: true
type: array
deletions:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
nullable: true
type: array
required:
- additions
- deletions
type: object
CoretypesResourceRef:
properties:
kind:
type: string
type:
$ref: '#/components/schemas/CoretypesType'
required:
- type
- kind
type: object
CoretypesType:
enum:
- user
- serviceaccount
- anonymous
- role
- organization
- metaresource
- metaresources
type: string
DashboardtypesDashboard:
properties:
createdAt:
@@ -5323,9 +5335,6 @@ components:
sub_tree_node_count:
minimum: 0
type: integer
time_unix:
minimum: 0
type: integer
trace_id:
type: string
trace_state:
@@ -5706,6 +5715,35 @@ paths:
summary: Check permissions
tags:
- authz
/api/v1/authz/resources:
get:
deprecated: false
description: Gets all the available resources
operationId: AuthzResources
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableResources'
status:
type: string
required:
- status
- data
type: object
description: OK
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
summary: Get resources
tags:
- authz
/api/v1/channels:
get:
deprecated: false
@@ -7041,20 +7079,20 @@ paths:
schema:
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
responses:
"201":
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
status:
type: string
required:
- status
- data
type: object
description: Created
description: OK
"400":
content:
application/json:
@@ -7210,7 +7248,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
$ref: '#/components/schemas/AuthtypesUpdateableAuthDomain'
responses:
"204":
description: No Content
@@ -8929,7 +8967,7 @@ paths:
properties:
data:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
$ref: '#/components/schemas/AuthtypesGettableObjects'
type: array
status:
type: string
@@ -9002,7 +9040,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CoretypesPatchableObjects'
$ref: '#/components/schemas/AuthtypesPatchableObjects'
responses:
"204":
content:
@@ -9377,9 +9415,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- serviceaccount:list
- ADMIN
- tokenizer:
- serviceaccount:list
- ADMIN
summary: List service accounts
tags:
- serviceaccount
@@ -9439,9 +9477,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- serviceaccount:create
- ADMIN
- tokenizer:
- serviceaccount:create
- ADMIN
summary: Create service account
tags:
- serviceaccount
@@ -9489,9 +9527,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- serviceaccount:delete
- ADMIN
- tokenizer:
- serviceaccount:delete
- ADMIN
summary: Deletes a service account
tags:
- serviceaccount
@@ -9546,9 +9584,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- serviceaccount:read
- ADMIN
- tokenizer:
- serviceaccount:read
- ADMIN
summary: Gets a service account
tags:
- serviceaccount
@@ -9606,9 +9644,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- serviceaccount:update
- ADMIN
- tokenizer:
- serviceaccount:update
- ADMIN
summary: Updates a service account
tags:
- serviceaccount
@@ -9660,9 +9698,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- serviceaccount:read
- ADMIN
- tokenizer:
- serviceaccount:read
- ADMIN
summary: List service account keys
tags:
- serviceaccount
@@ -9728,9 +9766,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- serviceaccount:create
- ADMIN
- tokenizer:
- serviceaccount:create
- ADMIN
summary: Create a service account key
tags:
- serviceaccount
@@ -9783,9 +9821,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- serviceaccount:delete
- ADMIN
- tokenizer:
- serviceaccount:delete
- ADMIN
summary: Revoke a service account key
tags:
- serviceaccount
@@ -9848,9 +9886,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- serviceaccount:update
- ADMIN
- tokenizer:
- serviceaccount:update
- ADMIN
summary: Updates a service account key
tags:
- serviceaccount
@@ -9909,9 +9947,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- serviceaccount:read
- ADMIN
- tokenizer:
- serviceaccount:read
- ADMIN
summary: Gets service account roles
tags:
- serviceaccount
@@ -9971,11 +10009,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- serviceaccount:attach
- role:attach
- ADMIN
- tokenizer:
- serviceaccount:attach
- role:attach
- ADMIN
summary: Create service account role
tags:
- serviceaccount
@@ -10022,11 +10058,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- serviceaccount:attach
- role:attach
- ADMIN
- tokenizer:
- serviceaccount:attach
- role:attach
- ADMIN
summary: Delete service account role
tags:
- serviceaccount

View File

@@ -1,171 +0,0 @@
# Authorization (FGA)
SigNoz uses OpenFGA for fine-grained authorization. Resources are modeled as FGA objects — the authz system checks whether a principal (user or service account) has a specific relation (read, update, delete, etc.) on a specific resource.
This guide explains how to enable FGA for a new entity.
## Overview
Enabling FGA for an entity involves four steps:
1. **Define the resource in `coretypes`** — Register the Kind, Resource, Type entries, and managed role transactions
2. **Switch routes to the Check middleware** — Replace role-based middleware with resource-level FGA checks
3. **Add a migration** — Backfill FGA tuples for existing organizations
## Step 1: Define the resource in `coretypes`
All FGA-managed entities are declared statically in the `pkg/types/coretypes/` package. You need to add entries in several registry files:
### 1a. Add a Kind
In `registry_kind.go`, add your kind to the `Kinds` slice and declare the variable:
```go
var Kinds = []Kind{
// ... existing kinds ...
KindMyEntity,
}
var (
// ... existing kinds ...
KindMyEntity = MustNewKind("my-entity")
)
```
### 1b. Add Resources
In `registry_resource.go`, add two resources — a `metaresource` (instance) and `metaresources` (collection):
```go
var Resources = []Resource{
// ... existing resources ...
ResourceMetaResourceMyEntity,
ResourceMetaResourcesMyEntity,
}
var (
// ... existing resources ...
ResourceMetaResourceMyEntity = NewResourceMetaResource(KindMyEntity)
ResourceMetaResourcesMyEntity = NewResourceMetaResources(KindMyEntity)
)
```
### 1c. Add managed role transactions
In `registry_managed_role.go`, add the transactions for each managed role. Use the service account entries as a reference:
```go
var ManagedRoleToTransactions = map[string][]Transaction{
SigNozAdminRoleName: {
// ... existing admin transactions ...
// my-entity — admin full CRUD
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMyEntity}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMyEntity}, WildCardSelectorString)},
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMyEntity}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindMyEntity}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindMyEntity}, WildCardSelectorString)},
},
SigNozEditorRoleName: {
// ... existing editor transactions ...
// my-entity — editor read only
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMyEntity}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindMyEntity}, WildCardSelectorString)},
},
SigNozViewerRoleName: {
// ... existing viewer transactions ...
// my-entity — viewer read only
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMyEntity}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResources, Kind: KindMyEntity}, WildCardSelectorString)},
},
}
```
The `authtypes.Registry` (which wraps `coretypes.Registry`) will automatically bridge these static definitions into operational `*authtypes.Transaction` instances at construction time.
## Step 2: Switch routes to the Check middleware
In your route file (e.g., `pkg/apiserver/signozapiserver/myentity.go`), replace `AdminAccess` / `EditAccess` / `ViewAccess` with the `Check` middleware:
```go
provider.authZ.Check(
handler, // the HTTP handler func
authtypes.Relation{Verb: coretypes.VerbRead}, // the relation to check
coretypes.ResourceMetaResourceMyEntity, // the coretypes.Resource
selectorCallback, // extracts resource ID from the request
roles, // role names for community edition fallback
)
```
### Selector callbacks
You need two callbacks — one for collection operations, one for instance operations:
```go
// For create/list — wildcard selector on the collection.
func myEntityCollectionSelector(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeMetaResources.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
// For read/update/delete — specific instance ID + wildcard.
func myEntityInstanceSelector(req *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
id := mux.Vars(req)["id"]
idSelector, err := coretypes.TypeMetaResource.Selector(id)
if err != nil {
return nil, err
}
return []coretypes.Selector{
idSelector,
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
```
The instance callback includes a wildcard selector so that roles with wildcard permission (`*`) also match. Use `Type.Selector()` (not `MustSelector`) for user-supplied path parameters to avoid panics on malformed input.
### Role fallback
The `roles` parameter is used by the **community edition**, where `CheckWithTupleCreation` only checks role membership (ignoring resource selectors). Pass the role names that should have access:
```go
var myEntityAdminRoles = []string{authtypes.SigNozAdminRoleName}
var myEntityReadRoles = []string{authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName}
```
### OpenAPI security schemes
Use `newScopedSecuritySchemes` with the exact FGA scope, generated via `Resource.Scope(verb)`:
```go
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourceMyEntity.Scope(coretypes.VerbRead),
}),
// produces: ["my-entity:read"]
```
## Step 3: Add a migration for existing organizations
New organizations get FGA tuples automatically during bootstrap (via `CreateManagedUserRoleTransactions`). Existing organizations need a SQL migration to backfill the tuples.
Create a migration file in `pkg/sqlmigration/` (use the next available number). Follow the pattern in `078_add_sa_managed_role_txn.go`:
1. Select the OpenFGA store ID
2. Iterate all organizations
3. For each org x tuple, insert into the `tuple` and `changelog` tables
4. Use `ON CONFLICT DO NOTHING` for idempotency
5. Handle both PostgreSQL and SQLite dialects
Register the migration in `pkg/signoz/provider.go`.
## Checklist
- [ ] Kind added to `coretypes/registry_kind.go`
- [ ] Resources added to `coretypes/registry_resource.go` (both metaresource and metaresources)
- [ ] Managed role transactions added to `coretypes/registry_managed_role.go`
- [ ] Routes switched from `AdminAccess`/`EditAccess`/`ViewAccess` to `Check` middleware
- [ ] Selector callbacks use `Type.Selector()` (not `MustSelector`) for user-supplied IDs
- [ ] OpenAPI `SecuritySchemes` use `newScopedSecuritySchemes` with exact scope strings
- [ ] Migration backfills FGA tuples for existing organizations
- [ ] `make go-build-community` and `make go-build-enterprise` compile
- [ ] `make go-test` passes

View File

@@ -11,7 +11,6 @@ We adhere to three primary style guides as our foundation:
We **recommend** (almost enforce) reviewing these guides before contributing to the codebase. They provide valuable insights into writing idiomatic Go code and will help you understand our approach to backend development. In addition, we have a few additional rules that make certain areas stricter than the above which can be found in area-specific files in this package:
- [Abstractions](abstractions.md) - When to introduce new types and intermediate representations
- [Authorization](authz.md) - Enabling FGA for new entities
- [Errors](errors.md) - Structured error handling
- [Endpoint](endpoint.md) - HTTP endpoint patterns
- [Flagger](flagger.md) - Feature flag patterns

View File

@@ -2,6 +2,7 @@ package openfgaauthz
import (
"context"
"slices"
"github.com/SigNoz/signoz/ee/authz/openfgaserver"
"github.com/SigNoz/signoz/pkg/authz"
@@ -12,7 +13,6 @@ import (
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
@@ -25,19 +25,19 @@ type provider struct {
openfgaServer *openfgaserver.Server
licensing licensing.Licensing
store authtypes.RoleStore
registry *authtypes.Registry
registry []authz.RegisterTypeable
settings factory.ScopedProviderSettings
onBeforeRoleDelete []authz.OnBeforeRoleDelete
}
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry *authtypes.Registry) factory.ProviderFactory[authz.AuthZ, authz.Config] {
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry ...authz.RegisterTypeable) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return factory.NewProviderFactory(factory.MustNewName("openfga"), func(ctx context.Context, ps factory.ProviderSettings, config authz.Config) (authz.AuthZ, error) {
return newOpenfgaProvider(ctx, ps, config, sqlstore, openfgaSchema, openfgaDataStore, licensing, onBeforeRoleDelete, registry)
})
}
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry *authtypes.Registry) (authz.AuthZ, error) {
pkgOpenfgaAuthzProvider := pkgopenfgaauthz.NewProviderFactory(sqlstore, openfgaSchema, openfgaDataStore, registry)
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, openfgaDataStore storage.OpenFGADatastore, licensing licensing.Licensing, onBeforeRoleDelete []authz.OnBeforeRoleDelete, registry []authz.RegisterTypeable) (authz.AuthZ, error) {
pkgOpenfgaAuthzProvider := pkgopenfgaauthz.NewProviderFactory(sqlstore, openfgaSchema, openfgaDataStore)
pkgAuthzService, err := pkgOpenfgaAuthzProvider.New(ctx, settings, config)
if err != nil {
return nil, err
@@ -74,11 +74,11 @@ func (provider *provider) Stop(ctx context.Context) error {
return provider.openfgaServer.Stop(ctx)
}
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable coretypes.Resource, selectors []coretypes.Selector, roleSelectors []coretypes.Selector) error {
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, roleSelectors []authtypes.Selector) error {
return provider.openfgaServer.CheckWithTupleCreation(ctx, claims, orgID, relation, typeable, selectors, roleSelectors)
}
func (provider *provider) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable coretypes.Resource, selectors []coretypes.Selector, roleSelectors []coretypes.Selector) error {
func (provider *provider) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, roleSelectors []authtypes.Selector) error {
return provider.openfgaServer.CheckWithTupleCreationWithoutClaims(ctx, orgID, relation, typeable, selectors, roleSelectors)
}
@@ -108,7 +108,7 @@ func (provider *provider) CheckTransactions(ctx context.Context, subject string,
return results, nil
}
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType authtypes.Type) ([]*authtypes.Object, error) {
return provider.openfgaServer.ListObjects(ctx, subject, relation, objectType)
}
@@ -159,10 +159,16 @@ func (provider *provider) CreateManagedRoles(ctx context.Context, orgID valuer.U
func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
tuples := make([]*openfgav1.TupleKey, 0)
grantTuples := provider.getManagedRoleGrantTuples(orgID, userID)
grantTuples, err := provider.getManagedRoleGrantTuples(orgID, userID)
if err != nil {
return err
}
tuples = append(tuples, grantTuples...)
managedRoleTuples := provider.getManagedRoleTransactionTuples(orgID)
managedRoleTuples, err := provider.getManagedRoleTransactionTuples(orgID)
if err != nil {
return err
}
tuples = append(tuples, managedRoleTuples...)
return provider.Write(ctx, tuples, nil)
@@ -202,7 +208,21 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
return role, nil
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
func (provider *provider) GetResources(_ context.Context) []*authtypes.Resource {
resources := make([]*authtypes.Resource, 0)
for _, register := range provider.registry {
for _, typeable := range register.MustGetTypeables() {
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})
}
}
for _, typeable := range provider.MustGetTypeables() {
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})
}
return resources
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*authtypes.Object, 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())
@@ -213,16 +233,16 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
return nil, err
}
objects := make([]*coretypes.Object, 0)
for _, objectType := range provider.registry.Types() {
if coretypes.ErrIfVerbNotValidForType(relation.Verb, objectType) != nil {
objects := make([]*authtypes.Object, 0)
for _, objectType := range provider.getUniqueTypes() {
if !slices.Contains(authtypes.TypeableRelations[objectType], relation) {
continue
}
resourceObjects, err := provider.
ListObjects(
ctx,
authtypes.MustNewSubject(coretypes.NewResourceRole(), storableRole.Name, orgID, &coretypes.VerbAssignee),
authtypes.MustNewSubject(authtypes.TypeableRole, storableRole.Name, orgID, &authtypes.RelationAssignee),
relation,
objectType,
)
@@ -245,7 +265,7 @@ func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *au
return provider.store.Update(ctx, orgID, role)
}
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*coretypes.Object) error {
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*authtypes.Object) 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())
@@ -298,63 +318,84 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return provider.store.Delete(ctx, orgID, id)
}
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) []*openfgav1.TupleKey {
func (provider *provider) MustGetTypeables() []authtypes.Typeable {
return []authtypes.Typeable{authtypes.TypeableRole, authtypes.TypeableResourcesRoles}
}
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) ([]*openfgav1.TupleKey, error) {
tuples := []*openfgav1.TupleKey{}
// Grant the admin role to the user
adminSubject := authtypes.MustNewSubject(coretypes.NewResourceUser(), userID.String(), orgID, nil)
adminTuple := authtypes.NewTuples(
coretypes.NewResourceRole(),
adminSubject := authtypes.MustNewSubject(authtypes.TypeableUser, userID.String(), orgID, nil)
adminTuple, err := authtypes.TypeableRole.Tuples(
adminSubject,
authtypes.Relation{Verb: coretypes.VerbAssignee},
[]coretypes.Selector{coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName)},
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, adminTuple...)
// Grant the admin role to the anonymous user
anonymousSubject := authtypes.MustNewSubject(coretypes.NewResourceAnonymous(), coretypes.AnonymousUser.String(), orgID, nil)
anonymousTuple := authtypes.NewTuples(
coretypes.NewResourceRole(),
anonymousSubject := authtypes.MustNewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
anonymousTuple, err := authtypes.TypeableRole.Tuples(
anonymousSubject,
authtypes.Relation{Verb: coretypes.VerbAssignee},
[]coretypes.Selector{coretypes.TypeRole.MustSelector(authtypes.SigNozAnonymousRoleName)},
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAnonymousRoleName),
},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, anonymousTuple...)
return tuples
return tuples, nil
}
func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*openfgav1.TupleKey {
func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
transactionsByRole := make(map[string][]*authtypes.Transaction)
for _, register := range provider.registry {
for roleName, txns := range register.MustGetManagedRoleTransactions() {
transactionsByRole[roleName] = append(transactionsByRole[roleName], txns...)
}
}
tuples := make([]*openfgav1.TupleKey, 0)
for roleName, transactions := range provider.registry.ManagedRoleTransactions() {
for roleName, transactions := range transactionsByRole {
for _, txn := range transactions {
resource := coretypes.MustNewResourceFromTypeAndKind(txn.Object.Resource.Type, txn.Object.Resource.Kind)
txnTuples := authtypes.NewTuples(
resource,
typeable := authtypes.MustNewTypeableFromType(txn.Object.Resource.Type, txn.Object.Resource.Name)
txnTuples, err := typeable.Tuples(
authtypes.MustNewSubject(
coretypes.NewResourceRole(),
authtypes.TypeableRole,
roleName,
orgID,
&coretypes.VerbAssignee,
&authtypes.RelationAssignee,
),
txn.Relation,
[]coretypes.Selector{txn.Object.Selector},
[]authtypes.Selector{txn.Object.Selector},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, txnTuples...)
}
}
return tuples
return tuples, nil
}
func (provider *provider) deleteTuples(ctx context.Context, roleName string, orgID valuer.UUID) error {
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
subject := authtypes.MustNewSubject(authtypes.TypeableRole, roleName, orgID, &authtypes.RelationAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
for _, objectType := range provider.registry.Types() {
for _, objectType := range provider.getUniqueTypes() {
typeTuples, err := provider.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
User: subject,
Object: objectType.StringValue() + ":",
@@ -383,3 +424,28 @@ func (provider *provider) deleteTuples(ctx context.Context, roleName string, org
return nil
}
func (provider *provider) getUniqueTypes() []authtypes.Type {
seen := make(map[string]struct{})
uniqueTypes := make([]authtypes.Type, 0)
for _, register := range provider.registry {
for _, typeable := range register.MustGetTypeables() {
typeKey := typeable.Type().StringValue()
if _, ok := seen[typeKey]; ok {
continue
}
seen[typeKey] = struct{}{}
uniqueTypes = append(uniqueTypes, typeable.Type())
}
}
for _, typeable := range provider.MustGetTypeables() {
typeKey := typeable.Type().StringValue()
if _, ok := seen[typeKey]; ok {
continue
}
seen[typeKey] = struct{}{}
uniqueTypes = append(uniqueTypes, typeable.Type())
}
return uniqueTypes
}

View File

@@ -1,6 +1,6 @@
module base
type organization
type organisation
relations
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
@@ -10,14 +10,12 @@ type user
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define attach: [user, serviceaccount, role#assignee]
type serviceaccount
type serviceaccount
relations
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define attach: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
type anonymous
@@ -28,7 +26,6 @@ type role
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define attach: [user, serviceaccount, role#assignee]
type metaresources
relations

View File

@@ -7,7 +7,6 @@ import (
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
@@ -34,18 +33,18 @@ func (server *Server) Stop(ctx context.Context) error {
return server.pkgAuthzService.Stop(ctx)
}
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable coretypes.Resource, selectors []coretypes.Selector, _ []coretypes.Selector) error {
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject := ""
switch claims.Principal {
case authtypes.PrincipalUser:
user, err := authtypes.NewSubject(coretypes.NewResourceUser(), claims.UserID, orgID, nil)
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
subject = user
case authtypes.PrincipalServiceAccount:
serviceAccount, err := authtypes.NewSubject(coretypes.NewResourceServiceAccount(), claims.ServiceAccountID, orgID, nil)
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
if err != nil {
return err
}
@@ -53,7 +52,10 @@ func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtyp
subject = serviceAccount
}
tupleSlice := authtypes.NewTuples(typeable, subject, relation, selectors, orgID)
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
tuples := make(map[string]*openfgav1.TupleKey, len(tupleSlice))
for idx, tuple := range tupleSlice {
@@ -74,13 +76,16 @@ func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtyp
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subjects are not authorized for requested access")
}
func (server *Server) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable coretypes.Resource, selectors []coretypes.Selector, _ []coretypes.Selector) error {
subject, err := authtypes.NewSubject(coretypes.NewResourceAnonymous(), coretypes.AnonymousUser.String(), orgID, nil)
func (server *Server) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
if err != nil {
return err
}
tupleSlice := authtypes.NewTuples(typeable, subject, relation, selectors, orgID)
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
tuples := make(map[string]*openfgav1.TupleKey, len(tupleSlice))
for idx, tuple := range tupleSlice {
@@ -105,7 +110,7 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
return server.pkgAuthzService.BatchCheck(ctx, tupleReq)
}
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType authtypes.Type) ([]*authtypes.Object, error) {
return server.pkgAuthzService.ListObjects(ctx, subject, relation, objectType)
}

View File

@@ -2,7 +2,6 @@ package openfgaserver
import (
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/openfga/openfga/pkg/storage"
@@ -11,11 +10,11 @@ import (
"github.com/openfga/openfga/pkg/storage/sqlite"
)
func NewSQLStore(store sqlstore.SQLStore, config authz.Config) (storage.OpenFGADatastore, error) {
func NewSQLStore(store sqlstore.SQLStore) (storage.OpenFGADatastore, error) {
switch store.BunDB().Dialect().Name().String() {
case "sqlite":
return sqlite.NewWithDB(store.SQLDB(), &sqlcommon.Config{
MaxTuplesPerWriteField: config.OpenFGA.MaxTuplesPerWrite,
MaxTuplesPerWriteField: 100,
MaxTypesPerModelField: 100,
})
case "pg":
@@ -25,7 +24,7 @@ func NewSQLStore(store sqlstore.SQLStore, config authz.Config) (storage.OpenFGAD
}
return postgres.NewWithDB(pgStore.Pool(), nil, &sqlcommon.Config{
MaxTuplesPerWriteField: config.OpenFGA.MaxTuplesPerWrite,
MaxTuplesPerWriteField: 100,
MaxTypesPerModelField: 100,
})
}

View File

@@ -14,7 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
@@ -88,7 +88,7 @@ func (module *module) GetDashboardByPublicID(ctx context.Context, id valuer.UUID
return dashboardtypes.NewDashboardFromStorableDashboard(storableDashboard), nil
}
func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id valuer.UUID, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id valuer.UUID, orgs []*types.Organization) ([]authtypes.Selector, valuer.UUID, error) {
orgIDs := make([]string, len(orgs))
for idx, org := range orgs {
orgIDs[idx] = org.ID.StringValue()
@@ -99,9 +99,9 @@ func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id
return nil, valuer.UUID{}, err
}
return []coretypes.Selector{
coretypes.TypeMetaResource.MustSelector(id.StringValue()),
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
return []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeMetaResource, id.StringValue()),
authtypes.MustNewSelector(authtypes.TypeMetaResource, authtypes.WildCardSelectorString),
}, storableDashboard.OrgID, nil
}
@@ -217,6 +217,28 @@ func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valu
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) MustGetTypeables() []authtypes.Typeable {
return module.pkgDashboardModule.MustGetTypeables()
}
func (module *module) MustGetManagedRoleTransactions() map[string][]*authtypes.Transaction {
return map[string][]*authtypes.Transaction{
authtypes.SigNozAnonymousRoleName: {
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
}
}
func (module *module) deletePublic(ctx context.Context, _ valuer.UUID, dashboardID valuer.UUID) error {
return module.store.DeletePublic(ctx, dashboardID.StringValue())
}

View File

@@ -6,8 +6,6 @@ import (
"io"
"net/http"
"log/slog"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
@@ -17,6 +15,7 @@ import (
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"log/slog"
)
func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) {
@@ -138,3 +137,4 @@ func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) {
aH.QueryRangeV4(w, r)
}
}

View File

@@ -42,8 +42,8 @@ import (
// Server runs HTTP, Mux and a grpc server
type Server struct {
config signoz.Config
signoz *signoz.SigNoz
config signoz.Config
signoz *signoz.SigNoz
// public http router
httpConn net.Listener
@@ -148,7 +148,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
s := &Server{
config: config,
signoz: signoz,
signoz: signoz,
httpHostPort: baseconst.HTTPHostPort,
unavailableChannel: make(chan healthcheck.Status),
usageManager: usageManager,
@@ -317,3 +317,4 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}

View File

@@ -23,11 +23,6 @@
"**/*.md",
"**/*.json",
"src/parser/**",
"src/TraceOperator/parser/**",
".claude",
".opencode",
"dist",
"playwright-report",
".temp_cache"
"src/TraceOperator/parser/**"
]
}

View File

@@ -23,7 +23,8 @@
"commitlint": "commitlint --edit $1",
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
"generate:permissions-type": "node scripts/generate-permissions-type.cjs"
},
"engines": {
"node": ">=22.0.0"

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const axios = require('axios');
const PERMISSIONS_TYPE_FILE = path.join(
__dirname,
'../src/hooks/useAuthZ/permissions.type.ts',
);
const SIGNOZ_INTEGRATION_IMAGE = 'signoz:integration';
const LOCAL_BACKEND_URL = 'http://localhost:8080';
function log(message) {
console.log(`[generate-permissions-type] ${message}`);
}
function getBackendUrlFromDocker() {
try {
const output = execSync(
`docker ps --filter "ancestor=${SIGNOZ_INTEGRATION_IMAGE}" --format "{{.Ports}}"`,
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
).trim();
if (!output) {
return null;
}
const portMatch = output.match(/0\.0\.0\.0:(\d+)->8080\/tcp/);
if (portMatch) {
return `http://localhost:${portMatch[1]}`;
}
const ipv6Match = output.match(/:::(\d+)->8080\/tcp/);
if (ipv6Match) {
return `http://localhost:${ipv6Match[1]}`;
}
} catch (err) {
log(`Warning: Could not get port from docker: ${err.message}`);
}
return null;
}
async function checkBackendHealth(url, maxAttempts = 3, delayMs = 1000) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await axios.get(`${url}/api/v1/health`, {
timeout: 5000,
validateStatus: (status) => status === 200,
});
return true;
} catch (err) {
if (attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, delayMs));
}
}
}
return false;
}
async function discoverBackendUrl() {
const dockerUrl = getBackendUrlFromDocker();
if (dockerUrl) {
log(`Found ${SIGNOZ_INTEGRATION_IMAGE} container, trying ${dockerUrl}...`);
if (await checkBackendHealth(dockerUrl)) {
log(`Backend found at ${dockerUrl} (from py-test-setup)`);
return dockerUrl;
}
log(`Backend at ${dockerUrl} is not responding`);
}
log(`Trying local backend at ${LOCAL_BACKEND_URL}...`);
if (await checkBackendHealth(LOCAL_BACKEND_URL)) {
log(`Backend found at ${LOCAL_BACKEND_URL}`);
return LOCAL_BACKEND_URL;
}
return null;
}
async function fetchResources(backendUrl) {
log('Fetching resources from API...');
const resourcesUrl = `${backendUrl}/api/v1/authz/resources`;
const { data: response } = await axios.get(resourcesUrl);
return response;
}
function transformResponse(apiResponse) {
if (!apiResponse.data) {
throw new Error('Invalid API response: missing data field');
}
const { resources, relations } = apiResponse.data;
return {
status: apiResponse.status || 'success',
data: {
resources: resources,
relations: relations,
},
};
}
function generateTypeScriptFile(data) {
const resourcesStr = data.data.resources
.map(
(r) =>
`\t\t\t{\n\t\t\t\tname: '${r.name}',\n\t\t\t\ttype: '${r.type}',\n\t\t\t},`,
)
.join('\n');
const relationsStr = Object.entries(data.data.relations)
.map(
([type, relations]) =>
`\t\t\t${type}: [${relations.map((r) => `'${r}'`).join(', ')}],`,
)
.join('\n');
return `// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY scripts/generate-permissions-type
export default {
\tstatus: '${data.status}',
\tdata: {
\t\tresources: [
${resourcesStr}
\t\t],
\t\trelations: {
${relationsStr}
\t\t},
\t},
} as const;
`;
}
async function main() {
try {
log('Starting permissions type generation...');
const backendUrl = await discoverBackendUrl();
if (!backendUrl) {
console.error('\n' + '='.repeat(80));
console.error('ERROR: No running SigNoz backend found!');
console.error('='.repeat(80));
console.error(
'\nThe permissions type generator requires a running SigNoz backend.',
);
console.error('\nFor local development, start the backend with:');
console.error(' make go-run-enterprise');
console.error(
'\nFor CI or integration testing, start the test environment with:',
);
console.error(' make py-test-setup');
console.error(
'\nIf running in CI and seeing this error, check that the py-test-setup',
);
console.error('step completed successfully before this step runs.');
console.error('='.repeat(80) + '\n');
process.exit(1);
}
log('Fetching resources...');
const apiResponse = await fetchResources(backendUrl);
log('Transforming response...');
const transformed = transformResponse(apiResponse);
log('Generating TypeScript file...');
const content = generateTypeScriptFile(transformed);
log(`Writing to ${PERMISSIONS_TYPE_FILE}...`);
fs.writeFileSync(PERMISSIONS_TYPE_FILE, content, 'utf8');
const rootDir = path.join(__dirname, '../..');
const relativePath = path.relative(
path.join(rootDir, 'frontend'),
PERMISSIONS_TYPE_FILE,
);
log('Linting generated file...');
execSync(`cd frontend && yarn oxlint ${relativePath}`, {
cwd: rootDir,
stdio: 'inherit',
});
log('Successfully generated permissions.type.ts');
} catch (error) {
log(`Error: ${error.message}`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { main };

View File

@@ -19,8 +19,8 @@ import type {
import type {
AuthtypesPostableAuthDomainDTO,
AuthtypesUpdatableAuthDomainDTO,
CreateAuthDomain201,
AuthtypesUpdateableAuthDomainDTO,
CreateAuthDomain200,
DeleteAuthDomainPathParameters,
GetAuthDomain200,
GetAuthDomainPathParameters,
@@ -126,7 +126,7 @@ export const createAuthDomain = (
authtypesPostableAuthDomainDTO: BodyType<AuthtypesPostableAuthDomainDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateAuthDomain201>({
return GeneratedAPIInstance<CreateAuthDomain200>({
url: `/api/v1/domains`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -388,13 +388,13 @@ export const invalidateGetAuthDomain = async (
*/
export const updateAuthDomain = (
{ id }: UpdateAuthDomainPathParameters,
authtypesUpdatableAuthDomainDTO: BodyType<AuthtypesUpdatableAuthDomainDTO>,
authtypesUpdateableAuthDomainDTO: BodyType<AuthtypesUpdateableAuthDomainDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/domains/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdatableAuthDomainDTO,
data: authtypesUpdateableAuthDomainDTO,
});
};
@@ -407,7 +407,7 @@ export const getUpdateAuthDomainMutationOptions = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
},
TContext
>;
@@ -416,7 +416,7 @@ export const getUpdateAuthDomainMutationOptions = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
},
TContext
> => {
@@ -433,7 +433,7 @@ export const getUpdateAuthDomainMutationOptions = <
Awaited<ReturnType<typeof updateAuthDomain>>,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -448,7 +448,7 @@ export type UpdateAuthDomainMutationResult = NonNullable<
Awaited<ReturnType<typeof updateAuthDomain>>
>;
export type UpdateAuthDomainMutationBody =
BodyType<AuthtypesUpdatableAuthDomainDTO>;
BodyType<AuthtypesUpdateableAuthDomainDTO>;
export type UpdateAuthDomainMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -463,7 +463,7 @@ export const useUpdateAuthDomain = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
},
TContext
>;
@@ -472,7 +472,7 @@ export const useUpdateAuthDomain = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
},
TContext
> => {

View File

@@ -4,16 +4,23 @@
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
AuthtypesTransactionDTO,
AuthzCheck200,
AuthzResources200,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -103,3 +110,88 @@ export const useAuthzCheck = <
return useMutation(mutationOptions);
};
/**
* Gets all the available resources
* @summary Get resources
*/
export const authzResources = (signal?: AbortSignal) => {
return GeneratedAPIInstance<AuthzResources200>({
url: `/api/v1/authz/resources`,
method: 'GET',
signal,
});
};
export const getAuthzResourcesQueryKey = () => {
return [`/api/v1/authz/resources`] as const;
};
export const getAuthzResourcesQueryOptions = <
TData = Awaited<ReturnType<typeof authzResources>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof authzResources>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getAuthzResourcesQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof authzResources>>> = ({
signal,
}) => authzResources(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof authzResources>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type AuthzResourcesQueryResult = NonNullable<
Awaited<ReturnType<typeof authzResources>>
>;
export type AuthzResourcesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get resources
*/
export function useAuthzResources<
TData = Awaited<ReturnType<typeof authzResources>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof authzResources>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getAuthzResourcesQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get resources
*/
export const invalidateAuthzResources = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getAuthzResourcesQueryKey() },
options,
);
return queryClient;
};

View File

@@ -18,9 +18,9 @@ import type {
} from 'react-query';
import type {
AuthtypesPatchableObjectsDTO,
AuthtypesPatchableRoleDTO,
AuthtypesPostableRoleDTO,
CoretypesPatchableObjectsDTO,
CreateRole201,
DeleteRolePathParameters,
GetObjects200,
@@ -571,13 +571,13 @@ export const invalidateGetObjects = async (
*/
export const patchObjects = (
{ id, relation }: PatchObjectsPathParameters,
coretypesPatchableObjectsDTO: BodyType<CoretypesPatchableObjectsDTO>,
authtypesPatchableObjectsDTO: BodyType<AuthtypesPatchableObjectsDTO>,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: coretypesPatchableObjectsDTO,
data: authtypesPatchableObjectsDTO,
});
};
@@ -590,7 +590,7 @@ export const getPatchObjectsMutationOptions = <
TError,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<CoretypesPatchableObjectsDTO>;
data: BodyType<AuthtypesPatchableObjectsDTO>;
},
TContext
>;
@@ -599,7 +599,7 @@ export const getPatchObjectsMutationOptions = <
TError,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<CoretypesPatchableObjectsDTO>;
data: BodyType<AuthtypesPatchableObjectsDTO>;
},
TContext
> => {
@@ -616,7 +616,7 @@ export const getPatchObjectsMutationOptions = <
Awaited<ReturnType<typeof patchObjects>>,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<CoretypesPatchableObjectsDTO>;
data: BodyType<AuthtypesPatchableObjectsDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -630,7 +630,7 @@ export const getPatchObjectsMutationOptions = <
export type PatchObjectsMutationResult = NonNullable<
Awaited<ReturnType<typeof patchObjects>>
>;
export type PatchObjectsMutationBody = BodyType<CoretypesPatchableObjectsDTO>;
export type PatchObjectsMutationBody = BodyType<AuthtypesPatchableObjectsDTO>;
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -645,7 +645,7 @@ export const usePatchObjects = <
TError,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<CoretypesPatchableObjectsDTO>;
data: BodyType<AuthtypesPatchableObjectsDTO>;
},
TContext
>;
@@ -654,7 +654,7 @@ export const usePatchObjects = <
TError,
{
pathParams: PatchObjectsPathParameters;
data: BodyType<CoretypesPatchableObjectsDTO>;
data: BodyType<AuthtypesPatchableObjectsDTO>;
},
TContext
> => {

View File

@@ -1641,31 +1641,135 @@ export interface AuthtypesCallbackAuthNSupportDTO {
url?: string;
}
export interface AuthtypesGettableAuthDomainDTO {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
config?: AuthtypesAuthDomainConfigDTO;
export type AuthtypesGettableAuthDomainDTO =
| (AuthtypesSamlConfigDTO & {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
googleAuthConfig?: AuthtypesGoogleConfigDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
oidcConfig?: AuthtypesOIDCConfigDTO;
/**
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
})
| (AuthtypesGoogleConfigDTO & {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
googleAuthConfig?: AuthtypesGoogleConfigDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
oidcConfig?: AuthtypesOIDCConfigDTO;
/**
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
})
| (AuthtypesOIDCConfigDTO & {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
googleAuthConfig?: AuthtypesGoogleConfigDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
oidcConfig?: AuthtypesOIDCConfigDTO;
/**
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
});
export interface AuthtypesGettableObjectsDTO {
resource: AuthtypesResourceDTO;
/**
* @type string
* @format date-time
* @type array
*/
createdAt?: Date;
selectors: string[];
}
/**
* @nullable
*/
export type AuthtypesGettableResourcesDTORelations = {
[key: string]: string[];
} | null;
export interface AuthtypesGettableResourcesDTO {
/**
* @type string
* @type object
* @nullable true
*/
id: string;
relations: AuthtypesGettableResourcesDTORelations;
/**
* @type string
* @type array
*/
name?: string;
/**
* @type string
*/
orgId?: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
resources: AuthtypesResourceDTO[];
}
export interface AuthtypesGettableTokenDTO {
@@ -1692,8 +1796,11 @@ export interface AuthtypesGettableTransactionDTO {
* @type boolean
*/
authorized: boolean;
object: CoretypesObjectDTO;
relation: AuthtypesRelationDTO;
object: AuthtypesObjectDTO;
/**
* @type string
*/
relation: string;
}
export type AuthtypesGoogleConfigDTODomainToAdminEmail = {
@@ -1767,6 +1874,14 @@ export interface AuthtypesOIDCConfigDTO {
issuerAlias?: string;
}
export interface AuthtypesObjectDTO {
resource: AuthtypesResourceDTO;
/**
* @type string
*/
selector: string;
}
export interface AuthtypesOrgSessionContextDTO {
authNSupport?: AuthtypesAuthNSupportDTO;
/**
@@ -1784,6 +1899,19 @@ export interface AuthtypesPasswordAuthNSupportDTO {
provider?: AuthtypesAuthNProviderDTO;
}
export interface AuthtypesPatchableObjectsDTO {
/**
* @type array
* @nullable true
*/
additions: AuthtypesGettableObjectsDTO[] | null;
/**
* @type array
* @nullable true
*/
deletions: AuthtypesGettableObjectsDTO[] | null;
}
export interface AuthtypesPatchableRoleDTO {
/**
* @type string
@@ -1832,14 +1960,17 @@ export interface AuthtypesPostableRotateTokenDTO {
refreshToken?: string;
}
export enum AuthtypesRelationDTO {
create = 'create',
read = 'read',
update = 'update',
delete = 'delete',
list = 'list',
assignee = 'assignee',
export interface AuthtypesResourceDTO {
/**
* @type string
*/
name: string;
/**
* @type string
*/
type: string;
}
export interface AuthtypesRoleDTO {
/**
* @type string
@@ -1929,11 +2060,14 @@ export interface AuthtypesSessionContextDTO {
}
export interface AuthtypesTransactionDTO {
object: CoretypesObjectDTO;
relation: AuthtypesRelationDTO;
object: AuthtypesObjectDTO;
/**
* @type string
*/
relation: string;
}
export interface AuthtypesUpdatableAuthDomainDTO {
export interface AuthtypesUpdateableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
}
@@ -4116,52 +4250,6 @@ export interface ConfigWechatConfigDTO {
to_user?: string;
}
export interface CoretypesObjectDTO {
resource: CoretypesResourceRefDTO;
/**
* @type string
*/
selector: string;
}
export interface CoretypesObjectGroupDTO {
resource: CoretypesResourceRefDTO;
/**
* @type array
*/
selectors: string[];
}
export interface CoretypesPatchableObjectsDTO {
/**
* @type array
* @nullable true
*/
additions: CoretypesObjectGroupDTO[] | null;
/**
* @type array
* @nullable true
*/
deletions: CoretypesObjectGroupDTO[] | null;
}
export interface CoretypesResourceRefDTO {
/**
* @type string
*/
kind: string;
type: CoretypesTypeDTO;
}
export enum CoretypesTypeDTO {
user = 'user',
serviceaccount = 'serviceaccount',
anonymous = 'anonymous',
role = 'role',
organization = 'organization',
metaresource = 'metaresource',
metaresources = 'metaresources',
}
export interface DashboardtypesDashboardDTO {
/**
* @type string
@@ -7703,11 +7791,6 @@ export interface TracedetailtypesWaterfallSpanDTO {
* @minimum 0
*/
sub_tree_node_count?: number;
/**
* @type integer
* @minimum 0
*/
time_unix?: number;
/**
* @type string
*/
@@ -8099,6 +8182,14 @@ export type AuthzCheck200 = {
status: string;
};
export type AuthzResources200 = {
data: AuthtypesGettableResourcesDTO;
/**
* @type string
*/
status: string;
};
export type ListChannels200 = {
/**
* @type array
@@ -8341,8 +8432,8 @@ export type ListAuthDomains200 = {
status: string;
};
export type CreateAuthDomain201 = {
data: TypesIdentifiableDTO;
export type CreateAuthDomain200 = {
data: AuthtypesGettableAuthDomainDTO;
/**
* @type string
*/
@@ -8724,7 +8815,7 @@ export type GetObjects200 = {
/**
* @type array
*/
data: CoretypesObjectGroupDTO[];
data: AuthtypesGettableObjectsDTO[];
/**
* @type string
*/

View File

@@ -437,11 +437,16 @@ export function convertTraceOperatorToV5(
panelType,
);
// Skip aggregation for raw request type
// Skip aggregation for raw request type. Force dataSource to traces so
// createAggregation never takes the metrics branch (which would emit a
// metricName field the backend rejects for trace operators).
const aggregations =
requestType === 'raw'
? undefined
: createAggregation(traceOperatorData, panelType);
: createAggregation(
{ ...traceOperatorData, dataSource: DataSource.TRACES },
panelType,
);
const spec: QueryEnvelope['spec'] = {
name: queryName,

View File

@@ -55,7 +55,7 @@ describe('GuardAuthZ', () => {
);
render(
<GuardAuthZ relation="read" object="role:*">
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
@@ -79,7 +79,7 @@ describe('GuardAuthZ', () => {
render(
<GuardAuthZ
relation="read"
object="role:*"
object="dashboard:*"
fallbackOnLoading={<LoadingFallback />}
>
<TestChild />
@@ -102,7 +102,7 @@ describe('GuardAuthZ', () => {
);
const { container } = render(
<GuardAuthZ relation="read" object="role:*">
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
@@ -121,7 +121,11 @@ describe('GuardAuthZ', () => {
);
render(
<GuardAuthZ relation="read" object="role:*" fallbackOnError={ErrorFallback}>
<GuardAuthZ
relation="read"
object="dashboard:*"
fallbackOnError={ErrorFallback}
>
<TestChild />
</GuardAuthZ>,
);
@@ -151,7 +155,7 @@ describe('GuardAuthZ', () => {
render(
<GuardAuthZ
relation="read"
object="role:*"
object="dashboard:*"
fallbackOnError={errorFallbackWithCapture}
>
<TestChild />
@@ -174,7 +178,7 @@ describe('GuardAuthZ', () => {
);
const { container } = render(
<GuardAuthZ relation="read" object="role:*">
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
@@ -197,7 +201,7 @@ describe('GuardAuthZ', () => {
render(
<GuardAuthZ
relation="update"
object="role:123"
object="dashboard:123"
fallbackOnNoPermissions={NoPermissionFallback}
>
<TestChild />
@@ -220,7 +224,7 @@ describe('GuardAuthZ', () => {
);
const { container } = render(
<GuardAuthZ relation="update" object="role:123">
<GuardAuthZ relation="update" object="dashboard:123">
<TestChild />
</GuardAuthZ>,
);
@@ -240,7 +244,7 @@ describe('GuardAuthZ', () => {
);
const { container } = render(
<GuardAuthZ relation="read" object="role:*">
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
@@ -253,7 +257,7 @@ describe('GuardAuthZ', () => {
});
it('should pass requiredPermissionName to fallbackOnNoPermissions', async () => {
const permission = buildPermission('update', 'role:123');
const permission = buildPermission('update', 'dashboard:123');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
@@ -265,7 +269,7 @@ describe('GuardAuthZ', () => {
render(
<GuardAuthZ
relation="update"
object="role:123"
object="dashboard:123"
fallbackOnNoPermissions={NoPermissionFallbackWithSuggestions}
>
<TestChild />
@@ -295,7 +299,7 @@ describe('GuardAuthZ', () => {
);
const { rerender } = render(
<GuardAuthZ relation="read" object="role:*">
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
@@ -305,7 +309,7 @@ describe('GuardAuthZ', () => {
});
rerender(
<GuardAuthZ relation="delete" object="role:456">
<GuardAuthZ relation="delete" object="dashboard:456">
<TestChild />
</GuardAuthZ>,
);

View File

@@ -1,126 +0,0 @@
import { ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import { parseAsString, useQueryState } from 'nuqs';
import { useStore } from 'zustand';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
import { QuerySearchV2Context } from './context';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
import { createExpressionStore } from './QuerySearchV2.store';
export interface QuerySearchV2ProviderProps {
queryParamKey: string;
initialExpression?: string;
/**
* @default false
*/
persistOnUnmount?: boolean;
children: ReactNode;
}
/**
* Provider component that creates a scoped zustand store and exposes
* expression state to children via context.
*/
export function QuerySearchV2Provider({
initialExpression = '',
persistOnUnmount = false,
queryParamKey,
children,
}: QuerySearchV2ProviderProps): JSX.Element {
const storeRef = useRef(createExpressionStore());
const store = storeRef.current;
const [urlExpression, setUrlExpression] = useQueryState(
queryParamKey,
parseAsString,
);
const committedExpression = useStore(store, (s) => s.committedExpression);
const setInputExpression = useStore(store, (s) => s.setInputExpression);
const commitExpression = useStore(store, (s) => s.commitExpression);
const initializeFromUrl = useStore(store, (s) => s.initializeFromUrl);
const resetExpression = useStore(store, (s) => s.resetExpression);
const isInitialized = useRef(false);
useEffect(() => {
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, initializeFromUrl]);
useEffect(() => {
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
}
}, [committedExpression, setUrlExpression, urlExpression]);
useEffect(() => {
return (): void => {
if (!persistOnUnmount) {
setUrlExpression(null);
resetExpression();
}
};
}, [persistOnUnmount, setUrlExpression, resetExpression]);
const handleChange = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
setInputExpression(userOnly);
},
[initialExpression, setInputExpression],
);
const handleRun = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
commitExpression(userOnly);
},
[initialExpression, commitExpression],
);
const combinedExpression = useMemo(
() => combineInitialAndUserExpression(initialExpression, committedExpression),
[initialExpression, committedExpression],
);
const contextValue = useMemo<QuerySearchV2ContextValue>(
() => ({
expression: combinedExpression,
userExpression: committedExpression,
initialExpression,
querySearchProps: {
initialExpression: initialExpression.trim() ? initialExpression : undefined,
onChange: handleChange,
onRun: handleRun,
},
}),
[
combinedExpression,
committedExpression,
initialExpression,
handleChange,
handleRun,
],
);
return (
<QuerySearchV2Context.Provider value={contextValue}>
{children}
</QuerySearchV2Context.Provider>
);
}

View File

@@ -1,60 +0,0 @@
import { createStore, StoreApi } from 'zustand';
export type QuerySearchV2Store = {
/**
* User-typed expression (local state, updates on typing)
*/
inputExpression: string;
/**
* Committed expression (synced to URL, updates on submit)
*/
committedExpression: string;
setInputExpression: (expression: string) => void;
commitExpression: (expression: string) => void;
resetExpression: () => void;
initializeFromUrl: (urlExpression: string) => void;
};
export interface QuerySearchProps {
initialExpression: string | undefined;
onChange: (expression: string) => void;
onRun: (expression: string) => void;
}
export interface QuerySearchV2ContextValue {
/**
* Combined expression: "initialExpression AND (userExpression)"
*/
expression: string;
userExpression: string;
initialExpression: string;
querySearchProps: QuerySearchProps;
}
export function createExpressionStore(): StoreApi<QuerySearchV2Store> {
return createStore<QuerySearchV2Store>((set) => ({
inputExpression: '',
committedExpression: '',
setInputExpression: (expression: string): void => {
set({ inputExpression: expression });
},
commitExpression: (expression: string): void => {
set({
inputExpression: expression,
committedExpression: expression,
});
},
resetExpression: (): void => {
set({
inputExpression: '',
committedExpression: '',
});
},
initializeFromUrl: (urlExpression: string): void => {
set({
inputExpression: urlExpression,
committedExpression: urlExpression,
});
},
}));
}

View File

@@ -1,95 +0,0 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { useQuerySearchV2Context } from '../context';
import {
QuerySearchV2Provider,
QuerySearchV2ProviderProps,
} from '../QuerySearchV2.provider';
const mockSetQueryState = jest.fn();
let mockUrlValue: string | null = null;
jest.mock('nuqs', () => ({
parseAsString: {},
useQueryState: jest.fn(() => [mockUrlValue, mockSetQueryState]),
}));
function createWrapper(
props: Partial<QuerySearchV2ProviderProps> = {},
): ({ children }: { children: ReactNode }) => JSX.Element {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QuerySearchV2Provider queryParamKey="testExpression" {...props}>
{children}
</QuerySearchV2Provider>
);
};
}
describe('QuerySearchExpressionProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlValue = null;
});
it('should provide initial context values', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper(),
});
expect(result.current.expression).toBe('');
expect(result.current.userExpression).toBe('');
expect(result.current.initialExpression).toBe('');
});
it('should combine initialExpression with userExpression', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper({ initialExpression: 'k8s.pod.name = "my-pod"' }),
});
expect(result.current.expression).toBe('k8s.pod.name = "my-pod"');
expect(result.current.initialExpression).toBe('k8s.pod.name = "my-pod"');
act(() => {
result.current.querySearchProps.onChange('service = "api"');
});
act(() => {
result.current.querySearchProps.onRun('service = "api"');
});
expect(result.current.expression).toBe(
'k8s.pod.name = "my-pod" AND (service = "api")',
);
expect(result.current.userExpression).toBe('service = "api"');
});
it('should provide querySearchProps with correct callbacks', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper({ initialExpression: 'initial' }),
});
expect(result.current.querySearchProps.initialExpression).toBe('initial');
expect(typeof result.current.querySearchProps.onChange).toBe('function');
expect(typeof result.current.querySearchProps.onRun).toBe('function');
});
it('should initialize from URL value on mount', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper(),
});
expect(result.current.userExpression).toBe('status = 500');
expect(result.current.expression).toBe('status = 500');
});
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useQuerySearchV2Context());
}).toThrow(
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
);
});
});

View File

@@ -1,61 +0,0 @@
import { createExpressionStore } from '../QuerySearchV2.store';
describe('createExpressionStore', () => {
it('should create a store with initial state', () => {
const store = createExpressionStore();
const state = store.getState();
expect(state.inputExpression).toBe('');
expect(state.committedExpression).toBe('');
});
it('should update inputExpression via setInputExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
expect(store.getState().inputExpression).toBe('service.name = "api"');
expect(store.getState().committedExpression).toBe('');
});
it('should update both expressions via commitExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
store.getState().commitExpression('service.name = "api"');
expect(store.getState().inputExpression).toBe('service.name = "api"');
expect(store.getState().committedExpression).toBe('service.name = "api"');
});
it('should reset all state via resetExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
store.getState().commitExpression('service.name = "api"');
store.getState().resetExpression();
expect(store.getState().inputExpression).toBe('');
expect(store.getState().committedExpression).toBe('');
});
it('should initialize from URL value', () => {
const store = createExpressionStore();
store.getState().initializeFromUrl('status = 500');
expect(store.getState().inputExpression).toBe('status = 500');
expect(store.getState().committedExpression).toBe('status = 500');
});
it('should create isolated store instances', () => {
const store1 = createExpressionStore();
const store2 = createExpressionStore();
store1.getState().setInputExpression('expr1');
store2.getState().setInputExpression('expr2');
expect(store1.getState().inputExpression).toBe('expr1');
expect(store2.getState().inputExpression).toBe('expr2');
});
});

View File

@@ -1,17 +0,0 @@
// eslint-disable-next-line no-restricted-imports -- React Context required for scoped store pattern
import { createContext, useContext } from 'react';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
export const QuerySearchV2Context =
createContext<QuerySearchV2ContextValue | null>(null);
export function useQuerySearchV2Context(): QuerySearchV2ContextValue {
const context = useContext(QuerySearchV2Context);
if (!context) {
throw new Error(
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
);
}
return context;
}

View File

@@ -1,8 +0,0 @@
export { useQuerySearchV2Context } from './context';
export type { QuerySearchV2ProviderProps } from './QuerySearchV2.provider';
export { QuerySearchV2Provider } from './QuerySearchV2.provider';
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2Store,
} from './QuerySearchV2.store';

View File

@@ -19,13 +19,6 @@
display: flex;
flex-direction: row;
.query-search-initial-scope-label {
position: absolute;
left: 8px;
top: 10px;
z-index: 10;
}
.query-where-clause-editor {
flex: 1;
min-width: 400px;
@@ -60,10 +53,6 @@
}
}
}
&.hasInitialExpression .cm-editor .cm-content {
padding-left: 22px !important;
}
}
.cm-editor {
@@ -79,6 +68,7 @@
border-radius: 2px;
border: 1px solid var(--l1-border);
padding: 0px !important;
background-color: var(--l1-background) !important;
&:focus-within {
border-color: var(--l1-border);

View File

@@ -30,7 +30,7 @@ import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariabl
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es';
import { Filter, Info, TriangleAlert } from 'lucide-react';
import { Info, TriangleAlert } from 'lucide-react';
import {
IDetailedError,
IQueryContext,
@@ -47,7 +47,6 @@ import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { queryExamples } from './constants';
import { combineInitialAndUserExpression } from './utils';
import './QuerySearch.styles.scss';
@@ -86,8 +85,6 @@ interface QuerySearchProps {
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
showFilterSuggestionsWithoutMetric?: boolean;
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
initialExpression?: string;
}
function QuerySearch({
@@ -99,7 +96,6 @@ function QuerySearch({
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
initialExpression,
}: QuerySearchProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
@@ -116,26 +112,18 @@ function QuerySearch({
const [isFocused, setIsFocused] = useState(false);
const editorRef = useRef<EditorView | null>(null);
const isScopedFilter = initialExpression !== undefined;
const validateExpressionForEditor = useCallback(
(editorDoc: string): void => {
const toValidate = isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', editorDoc)
: editorDoc;
try {
const validationResponse = validateQuery(toValidate);
setValidation(validationResponse);
} catch (error) {
setValidation({
isValid: false,
message: 'Failed to process query',
errors: [error as IDetailedError],
});
}
},
[initialExpression, isScopedFilter],
);
const handleQueryValidation = useCallback((newExpression: string): void => {
try {
const validationResponse = validateQuery(newExpression);
setValidation(validationResponse);
} catch (error) {
setValidation({
isValid: false,
message: 'Failed to process query',
errors: [error as IDetailedError],
});
}
}, []);
const getCurrentExpression = useCallback(
(): string => editorRef.current?.state.doc.toString() || '',
@@ -177,8 +165,6 @@ function QuerySearch({
setIsEditorReady(true);
}, []);
const prevQueryDataExpressionRef = useRef<string | undefined>();
useEffect(
() => {
if (!isEditorReady) {
@@ -187,22 +173,13 @@ function QuerySearch({
const newExpression = queryData.filter?.expression || '';
const currentExpression = getCurrentExpression();
const prevExpression = prevQueryDataExpressionRef.current;
// Only sync editor when queryData.filter?.expression actually changed from external source
// Not when focus changed (which would reset uncommitted user input)
const queryDataExpressionChanged = prevExpression !== newExpression;
prevQueryDataExpressionRef.current = newExpression;
if (
queryDataExpressionChanged &&
newExpression !== currentExpression &&
!isFocused
) {
// Do not update codemirror editor if the expression is the same
if (newExpression !== currentExpression && !isFocused) {
updateEditorValue(newExpression, { skipOnChange: true });
}
if (!isFocused) {
validateExpressionForEditor(currentExpression);
if (newExpression) {
handleQueryValidation(newExpression);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -307,7 +284,7 @@ function QuerySearch({
}
});
}
setKeySuggestions([...merged.values()]);
setKeySuggestions(Array.from(merged.values()));
// Force reopen the completion if editor is available and focused
if (editorRef.current) {
@@ -360,7 +337,7 @@ function QuerySearch({
// If value contains single quotes, escape them and wrap in single quotes
if (value.includes("'")) {
// Replace single quotes with escaped single quotes
const escapedValue = value.replaceAll(/'/g, "\\'");
const escapedValue = value.replace(/'/g, "\\'");
return `'${escapedValue}'`;
}
@@ -637,7 +614,7 @@ function QuerySearch({
const handleBlur = (): void => {
const currentExpression = getCurrentExpression();
validateExpressionForEditor(currentExpression);
handleQueryValidation(currentExpression);
setIsFocused(false);
};
@@ -655,6 +632,7 @@ function QuerySearch({
);
const handleExampleClick = (exampleQuery: string): void => {
// If there's an existing query, append the example with AND
const currentExpression = getCurrentExpression();
const newExpression = currentExpression
? `${currentExpression} AND ${exampleQuery}`
@@ -919,12 +897,12 @@ function QuerySearch({
// If we have previous pairs, we can prioritize keys that haven't been used yet
if (queryContext.queryPairs && queryContext.queryPairs.length > 0) {
const usedKeys = new Set(queryContext.queryPairs.map((pair) => pair.key));
const usedKeys = queryContext.queryPairs.map((pair) => pair.key);
// Add boost to unused keys to prioritize them
options = options.map((option) => ({
...option,
boost: usedKeys.has(option.label) ? -10 : 10,
boost: usedKeys.includes(option.label) ? -10 : 10,
}));
}
@@ -1339,19 +1317,6 @@ function QuerySearch({
)}
<div className="query-where-clause-editor-container">
{isScopedFilter ? (
<Tooltip title={initialExpression || ''} placement="left">
<div className="query-search-initial-scope-label">
<Filter
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</div>
</Tooltip>
) : null}
<Tooltip
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
@@ -1391,7 +1356,6 @@ function QuerySearch({
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter,
})}
extensions={[
autocompletion({
@@ -1426,12 +1390,7 @@ function QuerySearch({
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
const user = getCurrentExpression();
onRun(
isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', user)
: user,
);
onRun(getCurrentExpression());
}
return true;
},
@@ -1596,7 +1555,6 @@ QuerySearch.defaultProps = {
placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
showFilterSuggestionsWithoutMetric: false,
initialExpression: undefined,
};
export default QuerySearch;

View File

@@ -1,58 +0,0 @@
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
describe('entityLogsExpression', () => {
describe('combineInitialAndUserExpression', () => {
it('returns user when initial is empty', () => {
expect(combineInitialAndUserExpression('', 'body contains error')).toBe(
'body contains error',
);
});
it('returns initial when user is empty', () => {
expect(combineInitialAndUserExpression('k8s.pod.name = "x"', '')).toBe(
'k8s.pod.name = "x"',
);
});
it('wraps user in parentheses with AND', () => {
expect(
combineInitialAndUserExpression('k8s.pod.name = "x"', 'body = "a"'),
).toBe('k8s.pod.name = "x" AND (body = "a")');
});
});
describe('getUserExpressionFromCombined', () => {
it('returns empty when combined equals initial', () => {
expect(
getUserExpressionFromCombined('k8s.pod.name = "x"', 'k8s.pod.name = "x"'),
).toBe('');
});
it('extracts user from wrapped form', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND (body = "a")',
),
).toBe('body = "a"');
});
it('extracts user from legacy AND without parens', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND body = "a"',
),
).toBe('body = "a"');
});
it('returns full combined when initial is empty', () => {
expect(getUserExpressionFromCombined('', 'service.name = "a"')).toBe(
'service.name = "a"',
);
});
});
});

View File

@@ -1,40 +0,0 @@
export function combineInitialAndUserExpression(
initial: string,
user: string,
): string {
const i = initial.trim();
const u = user.trim();
if (!i) {
return u;
}
if (!u) {
return i;
}
return `${i} AND (${u})`;
}
export function getUserExpressionFromCombined(
initial: string,
combined: string | null | undefined,
): string {
const i = initial.trim();
const c = (combined ?? '').trim();
if (!c) {
return '';
}
if (!i) {
return c;
}
if (c === i) {
return '';
}
const wrappedPrefix = `${i} AND (`;
if (c.startsWith(wrappedPrefix) && c.endsWith(')')) {
return c.slice(wrappedPrefix.length, -1);
}
const plainPrefix = `${i} AND `;
if (c.startsWith(plainPrefix)) {
return c.slice(plainPrefix.length);
}
return c;
}

View File

@@ -1,14 +0,0 @@
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2ProviderProps,
} from './QueryV2/QuerySearch/Provider';
export {
QuerySearchV2Provider,
useQuerySearchV2Context,
} from './QueryV2/QuerySearch/Provider';
export { QueryBuilderV2 } from './QueryBuilderV2';
export {
QueryBuilderV2Provider,
useQueryBuilderV2Context,
} from './QueryBuilderV2Context';

View File

@@ -41,7 +41,11 @@ describe('createGuardedRoute', () => {
}),
);
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
@@ -75,7 +79,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'role:{id}',
'dashboard:{id}',
);
const mockMatch = {
@@ -109,7 +113,7 @@ describe('createGuardedRoute', () => {
relation: txn.relation,
object: {
resource: {
kind: txn.object.resource.kind,
name: txn.object.resource.name,
type: txn.object.resource.type,
},
selector: '123:456',
@@ -127,7 +131,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'update',
'role:{id}:{version}',
'dashboard:{id}:{version}',
);
const mockMatch = {
@@ -162,7 +166,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'role:{id}',
'dashboard:{id}',
);
const mockMatch = {
@@ -197,7 +201,11 @@ describe('createGuardedRoute', () => {
}),
);
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
@@ -228,7 +236,11 @@ describe('createGuardedRoute', () => {
}),
);
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
@@ -266,7 +278,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'update',
'role:{id}',
'dashboard:{id}',
);
const mockMatch = {
@@ -292,7 +304,7 @@ describe('createGuardedRoute', () => {
});
expect(screen.getByText('update')).toBeInTheDocument();
expect(screen.getByText('role:123')).toBeInTheDocument();
expect(screen.getByText('dashboard:123')).toBeInTheDocument();
expect(
screen.queryByText('Test Component: test-value'),
).not.toBeInTheDocument();
@@ -323,7 +335,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
ComponentWithMultipleProps,
'read',
'role:*',
'dashboard:*',
);
const mockMatch = {
@@ -358,10 +370,10 @@ describe('createGuardedRoute', () => {
requestCount++;
const payload = (await req.json()) as AuthtypesTransactionDTO[];
const obj = payload[0]?.object;
const kind = obj?.resource?.kind;
const name = obj?.resource?.name;
const selector = obj?.selector ?? '*';
const objectStr =
obj?.resource?.type === 'metaresources' ? kind : `${kind}:${selector}`;
obj?.resource?.type === 'metaresources' ? name : `${name}:${selector}`;
requestedObjects.push(objectStr ?? '');
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
@@ -371,7 +383,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'role:{id}',
'dashboard:{id}',
);
const mockMatch1 = {
@@ -395,7 +407,7 @@ describe('createGuardedRoute', () => {
});
expect(requestCount).toBe(1);
expect(requestedObjects).toContain('role:123');
expect(requestedObjects).toContain('dashboard:123');
unmount();
@@ -420,7 +432,7 @@ describe('createGuardedRoute', () => {
});
expect(requestCount).toBe(2);
expect(requestedObjects).toContain('role:456');
expect(requestedObjects).toContain('dashboard:456');
});
it('should handle different relation types', async () => {
@@ -434,7 +446,7 @@ describe('createGuardedRoute', () => {
const GuardedComponent = createGuardedRoute(
TestComponent,
'delete',
'role:{id}',
'dashboard:{id}',
);
const mockMatch = {

View File

@@ -6,7 +6,6 @@ export enum Events {
TOOLTIP_PINNED = 'TOOLTIP_PINNED',
TOOLTIP_UNPINNED = 'TOOLTIP_UNPINNED',
TOOLTIP_CONTENT_SCROLLED = 'TOOLTIP_CONTENT_SCROLLED',
TOOLTIP_SYNC_MODE_CHANGED = 'TOOLTIP_SYNC_MODE_CHANGED',
}
export enum InfraMonitoringEvents {

View File

@@ -38,5 +38,4 @@ export enum LOCALSTORAGE {
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
}

View File

@@ -107,7 +107,7 @@ describe('BillingContainer', () => {
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Cancel your subscription', { selector: 'span' }),
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
@@ -150,7 +150,7 @@ describe('BillingContainer', () => {
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
await expect(
screen.findByText('Cancel your subscription', { selector: 'span' }),
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
});
@@ -162,7 +162,7 @@ describe('BillingContainer', () => {
it('should render when license is ACTIVATED and platform is CLOUD', async () => {
render(<BillingContainer />);
await expect(
screen.findByText('Cancel your subscription', { selector: 'span' }),
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
@@ -186,7 +186,7 @@ describe('BillingContainer', () => {
);
await screen.findByText('billing');
expect(
screen.queryByText('Cancel your subscription', { selector: 'span' }),
screen.queryByText('Cancel Subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
@@ -225,7 +225,7 @@ describe('BillingContainer', () => {
render(<BillingContainer />, {}, { appContextOverrides: overrides });
await screen.findByText('billing');
expect(
screen.queryByText('Cancel your subscription', { selector: 'span' }),
screen.queryByText('Cancel Subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,11 +1,11 @@
.banner {
display: flex;
justify-content: space-between;
align-items: flex-start;
align-items: center;
padding: var(--padding-4);
border-radius: 4px;
border: 1px solid var(--l1-border);
background-color: var(--l2-background);
border: 1px solid var(--callout-error-border);
background-color: var(--callout-error-background);
margin: var(--spacing-4) 0 var(--spacing-12);
}
@@ -15,55 +15,21 @@
gap: var(--spacing-2);
}
.titleRow {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.title {
font-size: var(--paragraph-base-500-font-size);
font-weight: var(--paragraph-base-500-font-weight);
line-height: var(--paragraph-base-500-line-height);
color: var(--l1-foreground);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--callout-error-title);
}
.subtitle {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--l2-foreground);
padding-left: 20px;
color: var(--callout-error-icon);
}
.dialogBody {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.dialogDescription {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
color: var(--l2-foreground);
margin: 0;
}
.dialogConfirmLabel {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--l2-foreground);
margin: 0;
code {
font-family: var(--font-mono);
color: var(--l1-foreground);
}
}
.cancelButton {
background: var(--secondary-background);
border: 1px solid var(--l1-border);
}

View File

@@ -1,4 +1,4 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen, userEvent } from 'tests/test-utils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
@@ -13,16 +13,14 @@ describe('CancelSubscriptionBanner', () => {
it('renders banner with title and subtitle', () => {
render(<CancelSubscriptionBanner />);
expect(
screen.getByText('Cancel your subscription', { selector: 'span' }),
screen.getByText('Cancel Subscription', { selector: 'span' }),
).toBeInTheDocument();
expect(
screen.getByText(
/When you cancel your SigNoz subscription, all your data will be deleted/i,
),
screen.getByText('Cancel your SigNoz subscription.'),
).toBeInTheDocument();
});
it('opens dialog with content when Cancel Subscription is clicked', async () => {
it('opens dialog with correct content when Cancel Subscription is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
@@ -32,62 +30,17 @@ describe('CancelSubscriptionBanner', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(
screen.getByText(/Cancelling your subscription would stop your data/i),
screen.getByText(/reach out to our support team/i),
).toBeInTheDocument();
expect(screen.getByText(/Type/i)).toBeInTheDocument();
expect(
screen.getByPlaceholderText(/Enter the word cancel/i),
screen.getByRole('button', { name: /keep subscription/i }),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /cancel subscription/i }),
screen.getByRole('button', { name: /contact support/i }),
).toBeInTheDocument();
});
it('keeps Cancel subscription button disabled until "cancel" is typed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const confirmButton = screen.getByRole('button', {
name: /cancel subscription/i,
});
expect(confirmButton).toBeDisabled();
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'canc');
expect(confirmButton).toBeDisabled();
await user.type(input, 'el');
expect(confirmButton).toBeEnabled();
});
it('closes dialog and resets input when Go back is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'cancel');
await user.click(screen.getByRole('button', { name: /go back/i }));
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
expect(screen.getByPlaceholderText(/Enter the word cancel/i)).toHaveValue('');
});
it('sends mailto to cloud-support with correct subject after typing "cancel"', async () => {
it('sends mailto to cloud-support with correct subject on Contact Support', async () => {
const realCreateElement = document.createElement.bind(document);
const mockClick = jest.fn();
const mockAnchor = { href: '', click: mockClick };
@@ -104,13 +57,7 @@ describe('CancelSubscriptionBanner', () => {
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'cancel');
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.click(screen.getByRole('button', { name: /contact support/i }));
expect(mockAnchor.href).toContain('mailto:cloud-support@signoz.io');
expect(mockAnchor.href).toContain('Cancel%20My%20SigNoz%20Subscription');

View File

@@ -1,17 +1,15 @@
import { useState } from 'react';
import { SolidInfoCircle, Undo2, X } from '@signozhq/icons';
import { Button, DialogWrapper, Input } from '@signozhq/ui';
import { X } from '@signozhq/icons';
import { Button, DialogWrapper } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import { pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { getBaseUrl } from 'utils/basePath';
import styles from './CancelSubscriptionBanner.module.scss';
import { Color } from '@signozhq/design-tokens';
function CancelSubscriptionBanner(): JSX.Element {
const [open, setOpen] = useState(false);
const [confirmText, setConfirmText] = useState('');
const { user, org } = useAppContext();
const handleOpenCancelDialog = (): void => {
@@ -55,12 +53,6 @@ function CancelSubscriptionBanner(): JSX.Element {
link.href = `mailto:cloud-support@signoz.io?subject=${subject}&body=${body}`;
link.click();
setOpen(false);
setConfirmText('');
};
const handleClose = (): void => {
setOpen(false);
setConfirmText('');
};
const footer = (
@@ -68,19 +60,12 @@ function CancelSubscriptionBanner(): JSX.Element {
<Button
variant="solid"
color="secondary"
prefix={<Undo2 size={14} />}
onClick={handleClose}
onClick={(): void => setOpen(false)}
>
Go back
Keep Subscription
</Button>
<Button
variant="solid"
color="destructive"
prefix={<X size={14} />}
disabled={confirmText !== 'cancel'}
onClick={handleContactSupport}
>
Cancel subscription
<Button variant="solid" color="destructive" onClick={handleContactSupport}>
Contact Support
</Button>
</>
);
@@ -89,47 +74,30 @@ function CancelSubscriptionBanner(): JSX.Element {
<>
<div className={styles.banner}>
<div className={styles.info}>
<div className={styles.titleRow}>
<SolidInfoCircle color={Color.BG_SAKURA_500} size={12} />
<span className={styles.title}>Cancel your subscription</span>
</div>
<span className={styles.subtitle}>
When you cancel your SigNoz subscription, all your data will be deleted
immediately and removed from our servers.
</span>
<span className={styles.title}>Cancel Subscription</span>
<span className={styles.subtitle}>Cancel your SigNoz subscription.</span>
</div>
<Button
variant="solid"
color="secondary"
color="destructive"
prefix={<X size={12} />}
onClick={handleOpenCancelDialog}
className={styles.cancelButton}
>
Cancel Subscription
</Button>
</div>
<DialogWrapper
open={open}
onOpenChange={handleClose}
title="Cancel your subscription?"
onOpenChange={setOpen}
title="Cancel your subscription"
width="narrow"
showCloseButton={false}
footer={footer}
>
<div className={styles.dialogBody}>
<p className={styles.dialogDescription}>
Cancelling your subscription would stop your data from being ingested to
SigNoz. All the data that has been already sent will also be deleted.
</p>
<p className={styles.dialogConfirmLabel}>
Type <code>cancel</code> to confirm the cancellation.
</p>
<Input
placeholder="Enter the word cancel..."
value={confirmText}
onChange={(e): void => setConfirmText(e.target.value)}
/>
</div>
<p className={styles.dialogBody}>
To cancel your SigNoz subscription, please reach out to our support team.
We&apos;ll be happy to assist you.
</p>
</DialogWrapper>
</>
);

View File

@@ -220,6 +220,7 @@ export function buildCreateThresholdAlertRulePayload(
builderQueries: {
...mapQueryDataToApi(query.builder.queryData, 'queryName').data,
...mapQueryDataToApi(query.builder.queryFormulas, 'queryName').data,
...mapQueryDataToApi(query.builder.queryTraceOperator, 'queryName').data,
},
promQueries: mapQueryDataToApi(query.promql, 'name').data,
chQueries: mapQueryDataToApi(query.clickhouse_sql, 'name').data,

View File

@@ -1,190 +0,0 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
}
.overviewSettings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -0,0 +1,143 @@
.overview-content {
display: flex;
flex-direction: column;
.overview-settings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
.name-icon-input {
display: flex;
.dashboard-image-input {
.ant-select-selector {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
.ant-select-selection-item {
display: flex;
align-items: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}
.dashboard-name-input {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
.dashboard-name {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.description-text-area {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
.overview-settings-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
.unsaved {
display: flex;
align-items: center;
gap: 8px;
.unsaved-dot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsaved-changes {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px; /* 171.429% */
letter-spacing: -0.07px;
}
}
.footer-action-btns {
display: flex;
gap: 8px;
.discard-btn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.save-btn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
}
}
}
.dashboard-image-input {
&.ant-select-dropdown {
padding: 0px !important;
}
.ant-select-item {
padding: 0px;
align-items: center;
justify-content: center;
.ant-select-item-option-content {
display: flex;
align-items: center;
justify-content: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}

View File

@@ -1,24 +1,16 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
import { Col, Input, Select, Space, Typography } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, X } from 'lucide-react';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import styles from './GeneralSettings.module.scss';
import { Button } from './styles';
import { Base64Icons } from './utils';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
import './GeneralSettings.styles.scss';
const { Option } = Select;
@@ -27,13 +19,6 @@ function GeneralDashboardSettings(): JSX.Element {
const updateDashboardMutation = useUpdateDashboard();
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
dashboardData?.id,
);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(dashboardData?.id);
const selectedData = dashboardData?.data;
const {
@@ -115,8 +100,8 @@ function GeneralDashboardSettings(): JSX.Element {
};
return (
<div className={styles.overviewContent}>
<Col className={styles.overviewSettings}>
<div className="overview-content">
<Col className="overview-settings">
<Space
direction="vertical"
style={{
@@ -127,29 +112,27 @@ function GeneralDashboardSettings(): JSX.Element {
}}
>
<div>
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Dashboard Name
</Typography>
<section className="name-icon-input">
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName={styles.dashboardImageInput}
rootClassName="dashboard-image-input"
value={updatedImage}
onChange={(value: string): void => setUpdatedImage(value)}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
<img src={icon} alt="dashboard-icon" className="list-item-image" />
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
className="dashboard-name-input"
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
@@ -157,92 +140,41 @@ function GeneralDashboardSettings(): JSX.Element {
</div>
<div>
<Typography className={styles.dashboardName}>Description</Typography>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Description
</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={updatedDescription}
className={styles.descriptionTextArea}
className="description-text-area"
onChange={(e): void => setUpdatedDescription(e.target.value)}
/>
</div>
<div>
<Typography className={styles.dashboardName}>Tags</Typography>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Tags
</Typography>
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
</div>
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Sync Mode
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<Radio.Group
value={cursorSyncMode}
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Synced Tooltip Series
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Show only series that intersect on group-by, or every series with the
matching ones highlighted
</Typography.Text>
</div>
<Radio.Group
value={syncTooltipFilterMode}
onChange={(e): void => {
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: e.target.value,
});
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
</div>
)}
</Col>
{numberOfUnsavedChanges > 0 && (
<div className={styles.overviewSettingsFooter}>
<div className={styles.unsaved}>
<div className={styles.unsavedDot} />
<Typography.Text className={styles.unsavedChanges}>
<div className="overview-settings-footer">
<div className="unsaved">
<div className="unsaved-dot" />
<Typography.Text className="unsaved-changes">
{numberOfUnsavedChanges} unsaved change
{numberOfUnsavedChanges > 1 && 's'}
</Typography.Text>
</div>
<div className={styles.footerActionBtns}>
<div className="footer-action-btns">
<Button
disabled={updateDashboardMutation.isLoading}
icon={<X size={14} />}
onClick={discardHandler}
type="text"
className={styles.discardBtn}
className="discard-btn"
>
Discard
</Button>
@@ -256,7 +188,7 @@ function GeneralDashboardSettings(): JSX.Element {
data-testid="save-dashboard-config"
onClick={onSaveHandler}
type="primary"
className={styles.saveBtn}
className="save-btn"
>
{t('save')}
</Button>

View File

@@ -33,13 +33,11 @@ export default function BarChart(props: BarChartProps): JSX.Element {
}
const tooltipProps: BarTooltipProps = {
...props,
id: config.getId(),
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <BarChartTooltip {...tooltipProps} />;
},
@@ -50,7 +48,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
rest.decimalPrecision,
isStackedBarChart,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);

View File

@@ -29,12 +29,11 @@ export default function ChartWrapper({
onClick,
syncMode,
syncKey,
syncFilterMode,
onDestroy = noop,
children,
layoutChildren,
yAxisUnit,
groupByPerQuery,
groupBy,
customTooltip,
pinnedTooltipElement,
'data-testid': testId,
@@ -70,10 +69,9 @@ export default function ChartWrapper({
const syncMetadata = useMemo(
() => ({
yAxisUnit,
groupByPerQuery,
filterMode: syncFilterMode,
groupBy,
}),
[yAxisUnit, groupByPerQuery, syncFilterMode],
[yAxisUnit, groupBy],
);
return (

View File

@@ -24,21 +24,13 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
}
const tooltipProps: HistogramTooltipProps = {
...props,
id: rest.config.getId(),
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <HistogramTooltip {...tooltipProps} />;
},
[
customTooltip,
rest.yAxisUnit,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
[customTooltip, rest.yAxisUnit, rest.decimalPrecision, rest.canPinTooltip],
);
return (

View File

@@ -9,7 +9,7 @@ import {
import { TimeSeriesChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, customTooltip, ...rest } = props;
const { children, customTooltip, pinnedTooltipElement, ...rest } = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
@@ -18,12 +18,10 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
}
const tooltipProps: TimeSeriesTooltipProps = {
...props,
id: rest.config.getId(),
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <TimeSeriesTooltip {...tooltipProps} />;
},
@@ -33,12 +31,15 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
rest.yAxisUnit,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);
return (
<ChartWrapper {...rest} customTooltip={renderTooltip}>
<ChartWrapper
{...rest}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
{children}
</ChartWrapper>
);

View File

@@ -1,14 +1,9 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import {
IRenderTooltipFooterArgs,
LegendConfig,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
TooltipClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -26,7 +21,6 @@ interface BaseChartProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
}
@@ -36,7 +30,6 @@ interface UPlotBasedChartProps {
legendConfig: LegendConfig;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncFilterMode?: SyncTooltipFilterMode;
plotRef?: (plot: uPlot | null) => void;
onDestroy?: (plot: uPlot) => void;
children?: React.ReactNode;
@@ -46,7 +39,7 @@ interface UPlotBasedChartProps {
interface UPlotChartDataProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
groupBy?: BaseAutocompleteData[];
}
export interface TimeSeriesChartProps

View File

@@ -1,15 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
@@ -20,7 +14,7 @@ import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
import get from 'lodash/get';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -30,7 +24,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
onDragSelect,
isFullViewMode,
onToggleModelHandler,
groupByPerQuery,
} = props;
const uPlotRef = useRef<uPlot | null>(null);
const graphRef = useRef<HTMLDivElement>(null);
@@ -41,10 +34,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -86,11 +75,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
maxTimeScale,
timezone,
panelMode,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const chartData = useMemo(() => {
@@ -130,20 +114,14 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
uPlotRef.current = plot;
}, []);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
);
},
[],
);
const groupBy = useMemo(() => {
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={`${syncMode}-${syncFilterMode}`}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -155,14 +133,11 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
groupByPerQuery={groupByPerQuery}
groupBy={groupBy}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
syncMode={syncMode}
syncFilterMode={syncFilterMode}
renderTooltipFooter={renderTooltipFooter}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -1,11 +1,8 @@
import { useCallback, useMemo, useRef } from 'react';
import { useMemo, useRef } from 'react';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import uPlot from 'uplot';
import Histogram from '../../charts/Histogram/Histogram';
@@ -16,7 +13,6 @@ import {
} from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
function HistogramPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -79,20 +75,6 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
widget.mergeAllActiveQueries,
]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter
id={widget.id}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={false}
/>
);
},
[],
);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
@@ -115,7 +97,6 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
renderTooltipFooter={renderTooltipFooter}
/>
)}
</div>

View File

@@ -1,26 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { ContextMenu } from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import get from 'lodash/get';
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -30,7 +24,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
onDragSelect,
isFullViewMode,
onToggleModelHandler,
groupByPerQuery,
} = props;
const graphRef = useRef<HTMLDivElement>(null);
const [minTimeScale, setMinTimeScale] = useState<number>();
@@ -40,10 +33,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -92,11 +81,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
minTimeScale,
maxTimeScale,
timezone,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const layoutChildren = useMemo(() => {
@@ -121,20 +105,14 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
widget.decimalPrecision,
]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
);
},
[],
);
const groupBy = useMemo(() => {
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<TimeSeries
key={`${syncMode}-${syncFilterMode}`}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -144,13 +122,10 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
data={chartData as uPlot.AlignedData}
groupByPerQuery={groupByPerQuery}
groupBy={groupBy}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={syncMode}
syncFilterMode={syncFilterMode}
layoutChildren={layoutChildren}
renderTooltipFooter={renderTooltipFooter}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -1,93 +0,0 @@
import { Events } from 'constants/events';
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { render, screen, userEvent } from 'tests/test-utils';
import TooltipFooter from '../TooltipFooter';
const mockLogEvent = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): unknown => mockLogEvent(...args),
}));
describe('TooltipFooter', () => {
const defaultProps = {
id: 'panel-123',
isPinned: false,
dismiss: jest.fn(),
};
describe('when not pinned', () => {
it('renders the drilldown and pin hints by default', () => {
render(<TooltipFooter {...defaultProps} />);
expect(screen.getByText('Click to drilldown')).toBeInTheDocument();
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
expect(
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
).toBeInTheDocument();
});
it('hides the drilldown hint when canDrilldown is false', () => {
render(<TooltipFooter {...defaultProps} canDrilldown={false} />);
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
});
it('renders a custom pin key in uppercase', () => {
render(<TooltipFooter {...defaultProps} pinKey="x" />);
expect(screen.getByText('X')).toBeInTheDocument();
});
it('does not render the unpin button', () => {
render(<TooltipFooter {...defaultProps} />);
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
});
});
describe('when pinned', () => {
it('renders the unpin hint with pin key and Esc', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.getByText('to unpin')).toBeInTheDocument();
expect(
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
).toBeInTheDocument();
expect(screen.getByText('Esc')).toBeInTheDocument();
});
it('renders the unpin button', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.getByTestId('uplot-tooltip-unpin')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /unpin tooltip/i }),
).toBeInTheDocument();
});
it('hides the drilldown and pin-instruction hints', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
expect(screen.queryByText('to pin the tooltip')).not.toBeInTheDocument();
});
it('calls dismiss and logs the unpin event when the unpin button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const dismiss = jest.fn();
render(<TooltipFooter {...defaultProps} dismiss={dismiss} isPinned />);
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
id: 'panel-123',
});
expect(dismiss).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Compass, Dot, House, Plus, Wrench } from '@signozhq/icons';
import { Button } from '@signozhq/ui';
import { Button, PersistedAnnouncementBanner } from '@signozhq/ui';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetMetricsOnboardingStatus } from 'api/generated/services/metrics';
@@ -11,6 +11,7 @@ import listUserPreferences from 'api/v1/user/preferences/list';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import Header from 'components/Header/Header';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
@@ -270,6 +271,23 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
{user?.role === USER_ROLES.ADMIN && (
<PersistedAnnouncementBanner
type="info"
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
action={{
label: 'Go to Service Accounts',
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
}}
>
<>
<strong>API keys</strong> have been deprecated in favour of{' '}
<strong>Service accounts</strong>. The existing API Keys have been
migrated to service accounts.
</>
</PersistedAnnouncementBanner>
)}
<div className="sticky-header">
<Header
leftComponent={

View File

@@ -60,7 +60,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const [form] = Form.useForm<FormValues>();
const [authnProvider, setAuthnProvider] = useState<
AuthtypesAuthNProviderDTO | ''
>(record?.config?.ssoType || '');
>(record?.ssoType || '');
const { showErrorModal } = useErrorModal();
const { featureFlags } = useAppContext();

View File

@@ -112,26 +112,21 @@ export function prepareInitialValues(
};
}
const config = record.config ?? {};
return {
name: record.name,
ssoEnabled: config.ssoEnabled,
ssoType: config.ssoType,
samlConfig: config.samlConfig ?? undefined,
oidcConfig: config.oidcConfig ?? undefined,
googleAuthConfig: config.googleAuthConfig
...record,
googleAuthConfig: record.googleAuthConfig
? {
...config.googleAuthConfig,
...record.googleAuthConfig,
domainToAdminEmailList: convertDomainMappingsToList(
config.googleAuthConfig.domainToAdminEmail,
record.googleAuthConfig.domainToAdminEmail,
),
}
: undefined,
roleMapping: config.roleMapping
roleMapping: record.roleMapping
? {
...config.roleMapping,
...record.roleMapping,
groupMappingsList: convertGroupMappingsToList(
config.roleMapping.groupMappings,
record.roleMapping.groupMappings,
),
}
: undefined,

View File

@@ -43,11 +43,11 @@ function SSOEnforcementToggle({
data: {
config: {
ssoEnabled: checked,
ssoType: record.config?.ssoType,
googleAuthConfig: record.config?.googleAuthConfig,
oidcConfig: record.config?.oidcConfig,
samlConfig: record.config?.samlConfig,
roleMapping: record.config?.roleMapping,
ssoType: record.ssoType,
googleAuthConfig: record.googleAuthConfig,
oidcConfig: record.oidcConfig,
samlConfig: record.samlConfig,
roleMapping: record.roleMapping,
},
},
},

View File

@@ -55,10 +55,7 @@ describe('SSOEnforcementToggle', () => {
render(
<SSOEnforcementToggle
isDefaultChecked={false}
record={{
...mockGoogleAuthDomain,
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
}}
record={{ ...mockGoogleAuthDomain, ssoEnabled: false }}
/>,
);

View File

@@ -13,13 +13,11 @@ export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v1/domains/:id';
export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-1',
name: 'signoz.io',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-1',
@@ -30,14 +28,12 @@ export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-2',
name: 'example.com',
config: {
ssoEnabled: false,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
samlCert: 'MOCK_CERTIFICATE',
},
ssoEnabled: false,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
samlCert: 'MOCK_CERTIFICATE',
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-2',
@@ -48,14 +44,12 @@ export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-3',
name: 'corp.io',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
clientSecret: 'oidc-client-secret',
},
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
clientSecret: 'oidc-client-secret',
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-3',
@@ -66,22 +60,20 @@ export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-4',
name: 'enterprise.com',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.enterprise.com/sso',
samlEntity: 'urn:enterprise:idp',
samlCert: 'MOCK_CERTIFICATE',
},
roleMapping: {
defaultRole: 'EDITOR',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
},
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.enterprise.com/sso',
samlEntity: 'urn:enterprise:idp',
samlCert: 'MOCK_CERTIFICATE',
},
roleMapping: {
defaultRole: 'EDITOR',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
},
},
authNProviderInfo: {
@@ -94,18 +86,16 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
{
id: 'domain-5',
name: 'direct-role.com',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.direct-role.com',
clientId: 'direct-role-client-id',
clientSecret: 'direct-role-client-secret',
},
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: true,
},
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.direct-role.com',
clientId: 'direct-role-client-id',
clientSecret: 'direct-role-client-secret',
},
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: true,
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-5',
@@ -116,22 +106,20 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-6',
name: 'oidc-claims.com',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.claims.com',
issuerAlias: 'https://alias.claims.com',
clientId: 'claims-client-id',
clientSecret: 'claims-client-secret',
insecureSkipEmailVerified: true,
getUserInfo: true,
claimMapping: {
email: 'user_email',
name: 'display_name',
groups: 'user_groups',
role: 'user_role',
},
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.claims.com',
issuerAlias: 'https://alias.claims.com',
clientId: 'claims-client-id',
clientSecret: 'claims-client-secret',
insecureSkipEmailVerified: true,
getUserInfo: true,
claimMapping: {
email: 'user_email',
name: 'display_name',
groups: 'user_groups',
role: 'user_role',
},
},
authNProviderInfo: {
@@ -143,19 +131,17 @@ export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-7',
name: 'saml-attrs.com',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.saml-attrs.com/sso',
samlEntity: 'urn:saml-attrs:idp',
samlCert: 'MOCK_CERTIFICATE_ATTRS',
insecureSkipAuthNRequestsSigned: true,
attributeMapping: {
name: 'user_display_name',
groups: 'member_of',
role: 'signoz_role',
},
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.saml-attrs.com/sso',
samlEntity: 'urn:saml-attrs:idp',
samlCert: 'MOCK_CERTIFICATE_ATTRS',
insecureSkipAuthNRequestsSigned: true,
attributeMapping: {
name: 'user_display_name',
groups: 'member_of',
role: 'signoz_role',
},
},
authNProviderInfo: {
@@ -168,21 +154,19 @@ export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO =
{
id: 'domain-8',
name: 'google-groups.com',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'google-groups-client-id',
clientSecret: 'google-groups-client-secret',
insecureSkipEmailVerified: false,
fetchGroups: true,
serviceAccountJson: '{"type": "service_account"}',
domainToAdminEmail: {
'google-groups.com': 'admin@google-groups.com',
},
fetchTransitiveGroupMembership: true,
allowedGroups: ['allowed-group-1', 'allowed-group-2'],
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'google-groups-client-id',
clientSecret: 'google-groups-client-secret',
insecureSkipEmailVerified: false,
fetchGroups: true,
serviceAccountJson: '{"type": "service_account"}',
domainToAdminEmail: {
'google-groups.com': 'admin@google-groups.com',
},
fetchTransitiveGroupMembership: true,
allowedGroups: ['allowed-group-1', 'allowed-group-2'],
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-8',
@@ -207,19 +191,15 @@ export const mockSingleDomainResponse = {
data: [mockGoogleAuthDomain],
};
// Mock success responses. CreateAuthDomain returns just an Identifiable
// (the new domain ID); clients re-Read to get the full domain.
// Mock success responses
export const mockCreateSuccessResponse = {
status: 'success',
data: { id: mockGoogleAuthDomain.id },
data: mockGoogleAuthDomain,
};
export const mockUpdateSuccessResponse = {
status: 'success',
data: {
...mockGoogleAuthDomain,
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
},
data: { ...mockGoogleAuthDomain, ssoEnabled: false },
};
export const mockDeleteSuccessResponse = {

View File

@@ -158,7 +158,7 @@ function AuthDomain(): JSX.Element {
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.config?.ssoType || '')}
Configure {SSOType.get(record.ssoType || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"

View File

@@ -1,6 +1,5 @@
import { FC, useMemo } from 'react';
import { FC } from 'react';
import Spinner from 'components/Spinner';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { PanelTypeVsPanelWrapper } from './constants';
import { PanelWrapperProps } from './panelWrapper.types';
@@ -31,20 +30,6 @@ function PanelWrapper({
selectedGraph || widget.panelTypes
] as FC<PanelWrapperProps>;
const groupByPerQuery = useMemo<Record<string, BaseAutocompleteData[]>>(() => {
if (!widget.query.builder) {
return {};
}
const { queryData } = widget.query.builder;
return queryData.reduce<Record<string, BaseAutocompleteData[]>>(
(acc, query) => {
acc[query.queryName] = query.groupBy ?? [];
return acc;
},
{},
);
}, [widget]);
if (!Component) {
return <></>;
}
@@ -75,7 +60,6 @@ function PanelWrapper({
customSeries={customSeries}
enableDrillDown={enableDrillDown}
onColumnWidthsChange={onColumnWidthsChange}
groupByPerQuery={groupByPerQuery}
/>
);
}

View File

@@ -5,7 +5,6 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
import { WidgetGraphComponentProps } from 'container/GridCardLayout/GridCard/types';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
@@ -31,7 +30,6 @@ export type PanelWrapperProps = {
enableDrillDown?: boolean;
panelMode: PanelMode;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
};
export type TooltipData = {

View File

@@ -4,6 +4,7 @@ import { useHistory, useLocation } from 'react-router-dom';
import { Table2, Trash2, Users } from '@signozhq/icons';
import { Button, toast, ToggleGroup, ToggleGroupItem } from '@signozhq/ui';
import { Skeleton } from 'antd';
import { useAuthzResources } from 'api/generated/services/authz';
import {
getGetObjectsQueryKey,
useDeleteRole,
@@ -11,9 +12,6 @@ import {
useGetRole,
usePatchObjects,
} from 'api/generated/services/role';
import permissionsType from 'hooks/useAuthZ/permissions.type';
import type { AuthzResources } from '../utils';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ROUTES from 'constants/routes';
import { capitalize } from 'lodash-es';
@@ -54,7 +52,10 @@ function RoleDetailsPage(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const authzResources = permissionsType.data as unknown as AuthzResources;
const { data: authzResourcesResponse } = useAuthzResources({
query: { enabled: true },
});
const authzResources = authzResourcesResponse?.data ?? null;
// Extract channelId from URL pathname since useParams doesn't work in nested routing
const roleIdMatch = pathname.match(ROLE_ID_REGEX);
@@ -93,7 +94,7 @@ function RoleDetailsPage(): JSX.Element {
const initialConfig = useMemo(() => {
if (!objectsData?.data || !activePermission) {
return;
return undefined;
}
return objectsToPermissionConfig(
objectsData.data,

View File

@@ -15,6 +15,15 @@ const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
const MANAGED_ROLE_ID = '019c24aa-2248-756f-9833-984f1ab63819';
const rolesApiBase = 'http://localhost/api/v1/roles';
const authzResourcesUrl = 'http://localhost/api/v1/authz/resources';
const authzResourcesResponse = {
status: 'success',
data: {
relations: { create: ['dashboard'], read: ['dashboard'] },
resources: [{ name: 'dashboard', type: 'dashboard' }],
},
};
const emptyObjectsResponse = { status: 'success', data: [] };
@@ -36,6 +45,9 @@ function setupDefaultHandlers(roleId = CUSTOM_ROLE_ID): void {
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(roleResponse)),
),
rest.get(authzResourcesUrl, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(authzResourcesResponse)),
),
);
}

View File

@@ -1,18 +1,12 @@
import type {
CoretypesResourceRefDTO,
CoretypesObjectGroupDTO,
CoretypesTypeDTO,
AuthtypesGettableObjectsDTO,
AuthtypesGettableResourcesDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
PermissionConfig,
ResourceDefinition,
} from '../PermissionSidePanel/PermissionSidePanel.types';
type AuthzResources = {
resources: CoretypesResourceRefDTO[];
relations: Record<string, string[]>;
};
import { PermissionScope } from '../PermissionSidePanel/PermissionSidePanel.types';
import {
buildConfig,
@@ -39,17 +33,17 @@ jest.mock('../RoleDetails/constants', () => {
};
});
const dashboardResource: AuthzResources['resources'][number] = {
kind: 'dashboard',
type: 'metaresource' as CoretypesTypeDTO,
const dashboardResource: AuthtypesGettableResourcesDTO['resources'][number] = {
name: 'dashboard',
type: 'metaresource',
};
const alertResource: AuthzResources['resources'][number] = {
kind: 'alert',
type: 'metaresource' as CoretypesTypeDTO,
const alertResource: AuthtypesGettableResourcesDTO['resources'][number] = {
name: 'alert',
type: 'metaresource',
};
const baseAuthzResources: AuthzResources = {
const baseAuthzResources: AuthtypesGettableResourcesDTO = {
resources: [dashboardResource, alertResource],
relations: {
create: ['metaresource'],
@@ -226,7 +220,7 @@ describe('buildPatchPayload', () => {
describe('objectsToPermissionConfig', () => {
it('maps a wildcard selector to ALL scope', () => {
const objects: CoretypesObjectGroupDTO[] = [
const objects: AuthtypesGettableObjectsDTO[] = [
{ resource: dashboardResource, selectors: ['*'] },
];
@@ -239,7 +233,7 @@ describe('objectsToPermissionConfig', () => {
});
it('maps specific selectors to ONLY_SELECTED scope with the IDs', () => {
const objects: CoretypesObjectGroupDTO[] = [
const objects: AuthtypesGettableObjectsDTO[] = [
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
];
@@ -344,7 +338,7 @@ describe('buildConfig', () => {
describe('derivePermissionTypes', () => {
it('derives one PermissionType per relation key with correct key and capitalised label', () => {
const relations: AuthzResources['relations'] = {
const relations: AuthtypesGettableResourcesDTO['relations'] = {
create: ['metaresource'],
read: ['metaresource'],
delete: ['metaresource'],

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { Badge } from '@signozhq/ui';
import type {
CoretypesResourceRefDTO,
CoretypesObjectGroupDTO,
AuthtypesGettableObjectsDTO,
AuthtypesGettableResourcesDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { capitalize } from 'lodash-es';
@@ -19,11 +19,6 @@ import {
PERMISSION_ICON_MAP,
} from './RoleDetails/constants';
export type AuthzResources = {
resources: ReadonlyArray<CoretypesResourceRefDTO>;
relations: Readonly<Record<string, ReadonlyArray<string>>>;
};
export interface PermissionType {
key: string;
label: string;
@@ -34,11 +29,11 @@ export interface PatchPayloadOptions {
newConfig: PermissionConfig;
initialConfig: PermissionConfig;
resources: ResourceDefinition[];
authzRes: AuthzResources;
authzRes: AuthtypesGettableResourcesDTO;
}
export function derivePermissionTypes(
relations: AuthzResources['relations'] | null,
relations: AuthtypesGettableResourcesDTO['relations'] | null,
): PermissionType[] {
const iconSize = { size: 14 };
@@ -60,7 +55,7 @@ export function derivePermissionTypes(
}
export function deriveResourcesForRelation(
authzResources: AuthzResources | null,
authzResources: AuthtypesGettableResourcesDTO | null,
relation: string,
): ResourceDefinition[] {
if (!authzResources?.relations) {
@@ -70,19 +65,19 @@ export function deriveResourcesForRelation(
return authzResources.resources
.filter((r) => supportedTypes.includes(r.type))
.map((r) => ({
id: r.kind,
label: capitalize(r.kind).replaceAll('_', ' '),
id: r.name,
label: capitalize(r.name).replace(/_/g, ' '),
options: [],
}));
}
export function objectsToPermissionConfig(
objects: CoretypesObjectGroupDTO[],
objects: AuthtypesGettableObjectsDTO[],
resources: ResourceDefinition[],
): PermissionConfig {
const config: PermissionConfig = {};
for (const res of resources) {
const obj = objects.find((o) => o.resource.kind === res.id);
const obj = objects.find((o) => o.resource.name === res.id);
if (!obj) {
config[res.id] = {
scope: PermissionScope.ONLY_SELECTED,
@@ -106,19 +101,19 @@ export function buildPatchPayload({
resources,
authzRes,
}: PatchPayloadOptions): {
additions: CoretypesObjectGroupDTO[] | null;
deletions: CoretypesObjectGroupDTO[] | null;
additions: AuthtypesGettableObjectsDTO[] | null;
deletions: AuthtypesGettableObjectsDTO[] | null;
} {
if (!authzRes) {
return { additions: null, deletions: null };
}
const additions: CoretypesObjectGroupDTO[] = [];
const deletions: CoretypesObjectGroupDTO[] = [];
const additions: AuthtypesGettableObjectsDTO[] = [];
const deletions: AuthtypesGettableObjectsDTO[] = [];
for (const res of resources) {
const initial = initialConfig[res.id];
const current = newConfig[res.id];
const resourceDef = authzRes.resources.find((r) => r.kind === res.id);
const resourceDef = authzRes.resources.find((r) => r.name === res.id);
if (!resourceDef) {
continue;
}

View File

@@ -1,150 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useDashboardCursorSyncMode } from '../useDashboardCursorSyncMode';
import { useDashboardPreferencesStore } from '../useDashboardPreference';
const STORAGE_KEY = LOCALSTORAGE.DASHBOARD_PREFERENCES;
describe('useDashboardCursorSyncMode', () => {
beforeEach(() => {
useDashboardPreferencesStore.setState({ preferences: {} });
localStorage.removeItem(STORAGE_KEY);
});
describe('in DASHBOARD_VIEW mode', () => {
it('uses Crosshair as the default cursor sync mode', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
);
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
});
it('reads the stored cursor sync mode for the dashboard', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
);
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('writes the value under the cursorSyncMode key in the store', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(result.current[0]).toBe(DashboardCursorSync.None);
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('persists the value to localStorage', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
const persisted = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}');
expect(persisted.state.preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('returns the default when dashboardId is undefined', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
);
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
});
it('treats the setter as a no-op when dashboardId is undefined', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
);
act(() => {
result.current[1](DashboardCursorSync.Tooltip);
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
{},
);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
});
});
describe('without a panelMode (e.g. dashboard settings call site)', () => {
it('reads the stored value just like DASHBOARD_VIEW does', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('writes through the setter to the store', () => {
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(result.current[0]).toBe(DashboardCursorSync.None);
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
});
describe.each([[PanelMode.DASHBOARD_EDIT], [PanelMode.STANDALONE_VIEW]])(
'in %s mode (cursor sync disabled)',
(panelMode) => {
it('returns None and ignores any stored value', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', panelMode),
);
expect(result.current[0]).toBe(DashboardCursorSync.None);
});
it('treats the setter as a no-op and does not write to the store', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', panelMode),
);
act(() => {
result.current[1](DashboardCursorSync.Tooltip);
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
{},
);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(result.current[0]).toBe(DashboardCursorSync.None);
});
},
);
});

View File

@@ -1,201 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import {
useDashboardPreference,
useDashboardPreferencesStore,
} from '../useDashboardPreference';
const DEFAULT_MODE = DashboardCursorSync.Crosshair;
describe('useDashboardPreference', () => {
beforeEach(() => {
useDashboardPreferencesStore.setState({ preferences: {} });
});
it('returns the default value when no preference is stored', () => {
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DEFAULT_MODE);
});
it('returns the default value when dashboardId is undefined', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() =>
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DEFAULT_MODE);
});
it('returns the stored value for the given dashboardId', () => {
useDashboardPreferencesStore.setState({
preferences: {
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
},
});
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('persists the new value via the setter', () => {
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(result.current[0]).toBe(DashboardCursorSync.None);
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('does not write when dashboardId is undefined', () => {
const { result } = renderHook(() =>
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
result.current[1](DashboardCursorSync.Tooltip);
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({});
expect(result.current[0]).toBe(DEFAULT_MODE);
});
it('keeps multiple hook instances in sync after a write', () => {
const { result: writer } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
const { result: reader } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
writer.current[1](DashboardCursorSync.Tooltip);
});
expect(writer.current[0]).toBe(DashboardCursorSync.Tooltip);
expect(reader.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('isolates preferences across different dashboardIds', () => {
const { result: dashOne } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
const { result: dashTwo } = renderHook(() =>
useDashboardPreference('dash-2', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
dashOne.current[1](DashboardCursorSync.None);
});
expect(dashOne.current[0]).toBe(DashboardCursorSync.None);
expect(dashTwo.current[0]).toBe(DEFAULT_MODE);
});
it('does not overwrite preferences for other dashboards when writing', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
});
});
});
describe('useDashboardPreferencesStore.removePreferences', () => {
beforeEach(() => {
useDashboardPreferencesStore.setState({ preferences: {} });
});
it('removes the preferences for the given dashboardId', () => {
useDashboardPreferencesStore.setState({
preferences: {
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
},
});
act(() => {
useDashboardPreferencesStore.getState().removePreferences('dash-1');
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({});
});
it('leaves other dashboards untouched', () => {
useDashboardPreferencesStore.setState({
preferences: {
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
},
});
act(() => {
useDashboardPreferencesStore.getState().removePreferences('dash-1');
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('is a no-op when the dashboardId is not present', () => {
const initial = {
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
};
useDashboardPreferencesStore.setState({ preferences: initial });
const before = useDashboardPreferencesStore.getState().preferences;
act(() => {
useDashboardPreferencesStore.getState().removePreferences('dash-1');
});
// Identity-preserving so subscribers reading `preferences` don't re-render.
expect(useDashboardPreferencesStore.getState().preferences).toBe(before);
});
it('causes subsequent reads via useDashboardPreference to fall back to the default', () => {
useDashboardPreferencesStore.setState({
preferences: {
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
},
});
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
act(() => {
useDashboardPreferencesStore.getState().removePreferences('dash-1');
});
expect(result.current[0]).toBe(DEFAULT_MODE);
});
});

View File

@@ -1,26 +0,0 @@
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useDashboardPreference } from './useDashboardPreference';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
const NOOP = (): void => {};
export function useDashboardCursorSyncMode(
dashboardId: string | undefined,
panelMode?: PanelMode,
): [DashboardCursorSync, (value: DashboardCursorSync) => void] {
const [value, setValue] = useDashboardPreference(
dashboardId,
'cursorSyncMode',
DashboardCursorSync.Crosshair,
);
// Chart panels in edit / standalone modes don't participate in cross-panel
// sync, so surface the default with a no-op setter for them. Callers without
// a panelMode (e.g. dashboard settings) read/write the preference normally.
if (panelMode && panelMode !== PanelMode.DASHBOARD_VIEW) {
return [DashboardCursorSync.None, NOOP];
}
return [value, setValue];
}

View File

@@ -1,88 +0,0 @@
import { useCallback } from 'react';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
// Per-dashboard preferences persisted in localStorage. Add new preference
// fields here as they are introduced.
export type DashboardPreferences = {
cursorSyncMode?: DashboardCursorSync;
syncTooltipFilterMode?: SyncTooltipFilterMode;
};
interface DashboardPreferencesState {
preferences: Record<string, DashboardPreferences>;
setPreference: <K extends keyof DashboardPreferences>(
dashboardId: string,
key: K,
value: NonNullable<DashboardPreferences[K]>,
) => void;
removePreferences: (dashboardId: string) => void;
}
export const useDashboardPreferencesStore = create<DashboardPreferencesState>()(
persist(
(set) => ({
preferences: {},
setPreference: (dashboardId, key, value): void => {
set((state) => ({
preferences: {
...state.preferences,
[dashboardId]: {
...state.preferences[dashboardId],
[key]: value,
},
},
}));
},
removePreferences: (dashboardId): void => {
set((state) => {
if (!(dashboardId in state.preferences)) {
return state;
}
const { [dashboardId]: _, ...rest } = state.preferences;
return { preferences: rest };
});
},
}),
{ name: LOCALSTORAGE.DASHBOARD_PREFERENCES },
),
);
export function useDashboardPreference<K extends keyof DashboardPreferences>(
dashboardId: string | undefined,
key: K,
defaultValue: NonNullable<DashboardPreferences[K]>,
): [
NonNullable<DashboardPreferences[K]>,
(value: NonNullable<DashboardPreferences[K]>) => void,
] {
type Value = NonNullable<DashboardPreferences[K]>;
const value = useDashboardPreferencesStore((state): Value => {
if (!dashboardId) {
return defaultValue;
}
return (
(state.preferences[dashboardId]?.[key] as Value | undefined) ?? defaultValue
);
});
const setPreference = useDashboardPreferencesStore((s) => s.setPreference);
const updateValue = useCallback(
(next: Value): void => {
if (!dashboardId) {
return;
}
setPreference(dashboardId, key, next);
},
[dashboardId, key, setPreference],
);
return [value, updateValue];
}

View File

@@ -5,15 +5,10 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { useDashboardPreferencesStore } from './useDashboardPreference';
export const useDeleteDashboard = (
id: string,
): UseMutationResult<SuccessResponseV2<null>, APIError, void, unknown> => {
const { showErrorModal } = useErrorModal();
const removePreferences = useDashboardPreferencesStore(
(state) => state.removePreferences,
);
return useMutation<SuccessResponseV2<null>, APIError>({
mutationKey: REACT_QUERY_KEY.DELETE_DASHBOARD,
@@ -21,9 +16,6 @@ export const useDeleteDashboard = (
deleteDashboard({
id,
}),
onSuccess: () => {
removePreferences(id);
},
onError: (error: APIError) => {
showErrorModal(error);
},

View File

@@ -1,15 +0,0 @@
import { SyncTooltipFilterMode } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useDashboardPreference } from './useDashboardPreference';
const DEFAULT_SYNC_TOOLTIP_FILTER_MODE = SyncTooltipFilterMode.Filtered;
export function useSyncTooltipFilterMode(
dashboardId: string | undefined,
): [SyncTooltipFilterMode, (value: SyncTooltipFilterMode) => void] {
return useDashboardPreference(
dashboardId,
'syncTooltipFilterMode',
DEFAULT_SYNC_TOOLTIP_FILTER_MODE,
);
}

View File

@@ -1,29 +1,32 @@
// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY cmd/enterprise/*.go generate authz
// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY scripts/generate-permissions-type
export default {
status: 'success',
data: {
resources: [
{
kind: 'role',
name: 'dashboard',
type: 'metaresource',
},
{
name: 'dashboards',
type: 'metaresources',
},
{
kind: 'role',
name: 'role',
type: 'role',
},
{
kind: 'serviceaccount',
type: 'serviceaccount',
name: 'roles',
type: 'metaresources',
},
],
relations: {
assignee: ['role'],
attach: ['role', 'serviceaccount'],
create: ['metaresources'],
delete: ['role', 'serviceaccount'],
delete: ['user', 'serviceaccount', 'role', 'organization', 'metaresource'],
list: ['metaresources'],
read: ['role', 'serviceaccount'],
update: ['role', 'serviceaccount'],
read: ['user', 'serviceaccount', 'role', 'organization', 'metaresource'],
update: ['user', 'serviceaccount', 'role', 'organization', 'metaresource'],
},
},
} as const;

View File

@@ -1,16 +1,15 @@
import permissionsType from './permissions.type';
const ObjectSeparator = ':';
import { ObjectSeparator } from './utils';
type PermissionsData = typeof permissionsType.data;
export type Resource = PermissionsData['resources'][number];
export type ResourceName = Resource['kind'];
export type ResourceName = Resource['name'];
export type ResourceType = Resource['type'];
type RelationsByType = PermissionsData['relations'];
type ResourceTypeMap = {
[K in ResourceName]: Extract<Resource, { kind: K }>['type'];
[K in ResourceName]: Extract<Resource, { name: K }>['type'];
};
type RelationName = keyof RelationsByType;
@@ -18,7 +17,7 @@ type RelationName = keyof RelationsByType;
export type ResourcesForRelation<R extends RelationName> = Extract<
Resource,
{ type: RelationsByType[R][number] }
>['kind'];
>['name'];
type IsPluralResource<R extends ResourceName> =
ResourceTypeMap[R] extends 'metaresources' ? true : false;

View File

@@ -36,8 +36,8 @@ const wrapper = ({ children }: { children: ReactElement }): ReactElement => (
describe('useAuthZ', () => {
it('should fetch and return permissions successfully', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const expectedResponse = {
[permission1]: {
@@ -74,7 +74,7 @@ describe('useAuthZ', () => {
});
it('should handle API errors', async () => {
const permission = buildPermission('read', 'role:*');
const permission = buildPermission('read', 'dashboard:*');
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
@@ -95,9 +95,9 @@ describe('useAuthZ', () => {
});
it('should refetch when permissions array changes', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
const permission3 = buildPermission('delete', 'role:456');
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
let requestCount = 0;
@@ -161,8 +161,8 @@ describe('useAuthZ', () => {
});
it('should not refetch when permissions array order changes but content is the same', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
let requestCount = 0;
@@ -217,8 +217,8 @@ describe('useAuthZ', () => {
});
it('should send correct payload format to API', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
let receivedPayload: any = null;
@@ -244,23 +244,23 @@ describe('useAuthZ', () => {
expect(receivedPayload[0]).toMatchObject({
relation: 'read',
object: {
resource: { kind: 'role', type: 'role' },
resource: { name: 'dashboard', type: 'metaresource' },
selector: '*',
},
});
expect(receivedPayload[1]).toMatchObject({
relation: 'update',
object: {
resource: { kind: 'role', type: 'role' },
resource: { name: 'dashboard', type: 'metaresource' },
selector: '123',
},
});
});
it('should batch multiple hooks into single flight request', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
const permission3 = buildPermission('delete', 'role:456');
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
let requestCount = 0;
const receivedPayloads: any[] = [];
@@ -304,17 +304,17 @@ describe('useAuthZ', () => {
expect(receivedPayloads[0][0]).toMatchObject({
relation: 'read',
object: {
resource: { kind: 'role', type: 'role' },
resource: { name: 'dashboard', type: 'metaresource' },
selector: '*',
},
});
expect(receivedPayloads[0][1]).toMatchObject({
relation: 'update',
object: { resource: { kind: 'role' }, selector: '123' },
object: { resource: { name: 'dashboard' }, selector: '123' },
});
expect(receivedPayloads[0][2]).toMatchObject({
relation: 'delete',
object: { resource: { kind: 'role' }, selector: '456' },
object: { resource: { name: 'dashboard' }, selector: '456' },
});
expect(result1.current.permissions).toStrictEqual({
@@ -329,9 +329,9 @@ describe('useAuthZ', () => {
});
it('should create separate batches for calls after single flight window', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
const permission3 = buildPermission('delete', 'role:456');
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
let requestCount = 0;
const receivedPayloads: any[] = [];
@@ -386,18 +386,18 @@ describe('useAuthZ', () => {
expect(receivedPayloads[1]).toHaveLength(2);
expect(receivedPayloads[1][0]).toMatchObject({
relation: 'update',
object: { resource: { kind: 'role' }, selector: '123' },
object: { resource: { name: 'dashboard' }, selector: '123' },
});
expect(receivedPayloads[1][1]).toMatchObject({
relation: 'delete',
object: { resource: { kind: 'role' }, selector: '456' },
object: { resource: { name: 'dashboard' }, selector: '456' },
});
});
it('should map permissions correctly when API returns response out of order', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
const permission3 = buildPermission('delete', 'role:456');
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
@@ -435,8 +435,8 @@ describe('useAuthZ', () => {
});
it('should not leak state between separate batches', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
let requestCount = 0;

View File

@@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react';
import { useQueries } from 'react-query';
import { authzCheck } from 'api/generated/services/authz';
import type {
CoretypesObjectDTO,
AuthtypesObjectDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -34,7 +34,7 @@ function dispatchPermission(
});
setTimeout(() => {
const copiedPermissions = [...pendingPermissions];
const copiedPermissions = pendingPermissions.slice();
pendingPermissions = [];
ctx = null;
@@ -50,9 +50,9 @@ async function fetchManyPermissions(
): Promise<AuthZCheckResponse> {
const payload: AuthtypesTransactionDTO[] = permissions.map((permission) => {
const dto = permissionToTransactionDto(permission);
const object: CoretypesObjectDTO = {
const object: AuthtypesObjectDTO = {
resource: {
kind: dto.object.resource.kind,
name: dto.object.resource.name,
type: dto.object.resource.type,
},
selector: dto.object.selector,

View File

@@ -1,8 +1,4 @@
import {
AuthtypesTransactionDTO,
CoretypesTypeDTO,
AuthtypesRelationDTO,
} from '../../api/generated/services/sigNoz.schemas';
import { AuthtypesTransactionDTO } from '../../api/generated/services/sigNoz.schemas';
import permissionsType from './permissions.type';
import {
AuthZObject,
@@ -37,72 +33,36 @@ export function parsePermission(permission: BrandedPermission): {
return { relation: relation as AuthZRelation, object };
}
const kindsByType = permissionsType.data.resources.reduce(
const resourceNameToType = permissionsType.data.resources.reduce(
(acc, r) => {
if (!acc[r.type]) {
acc[r.type] = new Set();
}
acc[r.type].add(r.kind);
acc[r.name] = r.type;
return acc;
},
{} as Record<string, Set<string>>,
{} as Record<ResourceName, ResourceType>,
);
function resolveType(
relation: AuthZRelation,
kind: string,
): ResourceType | undefined {
const candidates: readonly string[] =
permissionsType.data.relations[relation] ?? [];
for (const t of candidates) {
if (kindsByType[t]?.has(kind)) {
return t as ResourceType;
}
}
return undefined;
}
function splitObjectString(objectStr: string): {
resourceName: string;
selector: string;
} {
const idx = objectStr.indexOf(ObjectSeparator);
if (idx === -1) {
return { resourceName: objectStr, selector: '' };
}
return {
resourceName: objectStr.slice(0, idx),
selector: objectStr.slice(idx + 1),
};
}
export function permissionToTransactionDto(
permission: BrandedPermission,
): AuthtypesTransactionDTO {
const { relation, object: objectStr } = parsePermission(permission);
const directType = resolveType(relation, objectStr);
const directType = resourceNameToType[objectStr as ResourceName];
if (directType === 'metaresources') {
return {
relation: relation as AuthtypesRelationDTO,
relation,
object: {
resource: {
kind: objectStr as ResourceName,
type: directType as CoretypesTypeDTO,
},
resource: { name: objectStr, type: directType },
selector: '*',
},
};
}
const { resourceName, selector } = splitObjectString(objectStr);
const type = resolveType(relation, resourceName) ?? 'metaresource';
const [resourceName, selector] = objectStr.split(ObjectSeparator);
const type =
resourceNameToType[resourceName as ResourceName] ?? 'metaresource';
return {
relation: relation as AuthtypesRelationDTO,
relation,
object: {
resource: {
kind: resourceName as ResourceName,
type: type as CoretypesTypeDTO,
},
resource: { name: resourceName, type },
selector: selector || '*',
},
};
@@ -115,7 +75,7 @@ export function gettableTransactionToPermission(
relation,
object: { resource, selector },
} = item;
const resourceName = String(resource.kind);
const resourceName = String(resource.name);
const selectorStr = typeof selector === 'string' ? selector : '*';
const objectStr =
resource.type === 'metaresources'

View File

@@ -53,7 +53,9 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
}
} else if (q.type === 'builder_trace_operator') {
if (spec.name) {
builderQueries[spec.name] = spec as unknown as IBuilderTraceOperator;
builderQueries[spec.name] = convertBuilderQueryToIBuilderQuery(
spec as BuilderQuery,
) as IBuilderTraceOperator;
builderQueryTypes[spec.name] = 'builder_trace_operator';
}
} else if (q.type === 'promql') {

View File

@@ -17,7 +17,6 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
decimalPrecision: props.decimalPrecision,
isStackedBarChart: props.isStackedBarChart,
syncedSeriesIndexes: props.syncedSeriesIndexes,
syncFilterMode: props.syncFilterMode,
}),
[
props.uPlotInstance,
@@ -27,7 +26,6 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
props.decimalPrecision,
props.isStackedBarChart,
props.syncedSeriesIndexes,
props.syncFilterMode,
],
);

View File

@@ -18,7 +18,6 @@ export default function HistogramTooltip(
yAxisUnit: props.yAxisUnit ?? '',
decimalPrecision: props.decimalPrecision,
syncedSeriesIndexes: props.syncedSeriesIndexes,
syncFilterMode: props.syncFilterMode,
}),
[
props.uPlotInstance,
@@ -27,7 +26,6 @@ export default function HistogramTooltip(
props.yAxisUnit,
props.decimalPrecision,
props.syncedSeriesIndexes,
props.syncFilterMode,
],
);

View File

@@ -18,7 +18,6 @@ export default function TimeSeriesTooltip(
yAxisUnit: props.yAxisUnit ?? '',
decimalPrecision: props.decimalPrecision,
syncedSeriesIndexes: props.syncedSeriesIndexes,
syncFilterMode: props.syncFilterMode,
}),
[
props.uPlotInstance,
@@ -27,7 +26,6 @@ export default function TimeSeriesTooltip(
props.yAxisUnit,
props.decimalPrecision,
props.syncedSeriesIndexes,
props.syncFilterMode,
],
);

View File

@@ -8,15 +8,15 @@
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
gap: 8px;
&.pinned {
border-color: var(--ring);
}
}
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
.divider {
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
}

View File

@@ -2,19 +2,19 @@ import { useMemo } from 'react';
import cx from 'classnames';
import { TooltipProps } from '../types';
import TooltipFooter from './components/TooltipFooter/TooltipFooter';
import TooltipHeader from './components/TooltipHeader/TooltipHeader';
import TooltipList from './components/TooltipList/TooltipList';
import Styles from './Tooltip.module.scss';
export default function Tooltip({
id,
uPlotInstance,
timezone,
content,
showTooltipHeader = true,
isPinned,
renderTooltipFooter,
canPinTooltip,
dismiss,
}: TooltipProps): JSX.Element {
const tooltipContent = useMemo(() => content ?? [], [content]);
@@ -31,9 +31,7 @@ export default function Tooltip({
return (
<div
className={cx(Styles.container, {
[Styles.pinned]: isPinned,
})}
className={cx(Styles.container, isPinned && Styles.pinned)}
data-testid="uplot-tooltip-container"
>
{showHeader && (
@@ -48,9 +46,9 @@ export default function Tooltip({
{showDivider && <span className={Styles.divider} />}
{showList && <TooltipList id={id} content={tooltipContent} />}
{showList && <TooltipList content={tooltipContent} />}
{renderTooltipFooter && renderTooltipFooter({ isPinned, dismiss })}
{canPinTooltip && <TooltipFooter isPinned={isPinned} dismiss={dismiss} />}
</div>
);
}

View File

@@ -7,7 +7,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { render, RenderResult, screen } from 'tests/test-utils';
import uPlot from 'uplot';
import { IRenderTooltipFooterArgs, TooltipContentItem } from '../../types';
import { TooltipContentItem } from '../../types';
import Tooltip from '../Tooltip';
type MockVirtuosoProps = {
@@ -83,7 +83,6 @@ function createUPlotInstance(cursorIdx: number | null): uPlot {
function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
const defaultProps: TooltipTestProps = {
id: 'tooltip-1',
uPlotInstance: createUPlotInstance(null),
timezone: { value: 'UTC', name: 'UTC', offset: '0', searchIndex: '0' },
content: [],
@@ -193,88 +192,63 @@ describe('Tooltip', () => {
});
});
describe('Tooltip renderTooltipFooter', () => {
describe('Tooltip footer hint', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsDarkMode.mockReturnValue(false);
});
it('does not render footer content when renderTooltipFooter is not provided', () => {
renderTooltip();
it('renders footer with "Press P to pin the tooltip" hint when not pinned', () => {
renderTooltip({ isPinned: false, canPinTooltip: true });
expect(screen.queryByTestId('custom-tooltip-footer')).not.toBeInTheDocument();
const footer = screen.getByTestId('uplot-tooltip-footer');
expect(footer).toBeInTheDocument();
expect(footer).toHaveTextContent('Press');
expect(footer).toHaveTextContent('P');
expect(footer).toHaveTextContent('to pin the tooltip');
});
it('renders content returned by renderTooltipFooter', () => {
const renderTooltipFooter = jest.fn(
(): JSX.Element => <div data-testid="custom-tooltip-footer">Footer</div>,
);
it('renders footer with "Press P or Esc to unpin" hint when pinned', () => {
renderTooltip({ isPinned: true, canPinTooltip: true });
renderTooltip({ renderTooltipFooter });
expect(screen.getByTestId('custom-tooltip-footer')).toBeInTheDocument();
const footer = screen.getByTestId('uplot-tooltip-footer');
expect(footer).toHaveTextContent('Press');
expect(footer).toHaveTextContent('P');
expect(footer).toHaveTextContent('Esc');
expect(footer).toHaveTextContent('to unpin');
});
it('calls renderTooltipFooter with isPinned=false when tooltip is not pinned', () => {
const renderTooltipFooter = jest.fn(() => null);
it('does not render Unpin button when not pinned', () => {
renderTooltip({ isPinned: false, canPinTooltip: true });
renderTooltip({ renderTooltipFooter, isPinned: false });
expect(renderTooltipFooter).toHaveBeenCalledWith(
expect.objectContaining({ isPinned: false }),
);
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
});
it('calls renderTooltipFooter with isPinned=true when tooltip is pinned', () => {
const renderTooltipFooter = jest.fn(() => null);
it('renders Unpin button when pinned', () => {
renderTooltip({ isPinned: true, canPinTooltip: true });
renderTooltip({ renderTooltipFooter, isPinned: true });
expect(renderTooltipFooter).toHaveBeenCalledWith(
expect.objectContaining({ isPinned: true }),
);
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
expect(unpinBtn).toBeInTheDocument();
expect(unpinBtn).toHaveAttribute('aria-label', 'Unpin tooltip');
});
it('calls renderTooltipFooter with the dismiss callback', () => {
it('calls dismiss when Unpin button is clicked', async () => {
const dismiss = jest.fn();
const renderTooltipFooter = jest.fn(() => null);
renderTooltip({ renderTooltipFooter, dismiss });
expect(renderTooltipFooter).toHaveBeenCalledWith(
expect.objectContaining({ dismiss }),
);
});
it('footer content reflects pinned state via renderTooltipFooter args', () => {
const renderTooltipFooter = jest.fn(
({ isPinned }: IRenderTooltipFooterArgs): JSX.Element => (
<div data-testid="footer-state">{isPinned ? 'Pinned' : 'Not pinned'}</div>
),
);
renderTooltip({ renderTooltipFooter, isPinned: true });
expect(screen.getByTestId('footer-state')).toHaveTextContent('Pinned');
});
it('dismiss is callable when invoked from renderTooltipFooter', async () => {
const dismiss = jest.fn();
const renderTooltipFooter = jest.fn(
({ dismiss: onDismiss }: IRenderTooltipFooterArgs): JSX.Element => (
<button data-testid="dismiss-btn" onClick={onDismiss}>
Dismiss
</button>
),
);
renderTooltip({ renderTooltipFooter, isPinned: true, dismiss });
renderTooltip({ isPinned: true, canPinTooltip: true, dismiss });
const user = userEvent.setup();
await user.click(screen.getByTestId('dismiss-btn'));
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
await user.click(unpinBtn);
expect(dismiss).toHaveBeenCalledTimes(1);
});
it('footer has role="status" for screen reader announcements', () => {
renderTooltip({ canPinTooltip: true });
const footer = screen.getByRole('status');
expect(footer).toBeInTheDocument();
});
});
describe('Tooltip header status pill', () => {

View File

@@ -7,25 +7,22 @@ import Styles from './TooltipFooter.module.scss';
import { MousePointerClick } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
interface TooltipFooterProps {
id: string;
pinKey?: string;
isPinned: boolean;
canDrilldown?: boolean;
dismiss: () => void;
}
export default function TooltipFooter({
id,
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
isPinned,
canDrilldown = true,
dismiss,
}: TooltipFooterProps): JSX.Element {
const handleUnpinClick = (): void => {
logEvent(Events.TOOLTIP_UNPINNED, {
id: id,
path: getAbsoluteUrl(window.location.pathname),
});
dismiss();
};
@@ -46,14 +43,12 @@ export default function TooltipFooter({
</div>
) : (
<div className={Styles.hintList}>
{canDrilldown && (
<div className={Styles.hint} data-active="false">
<Kbd>
<MousePointerClick size={12} />
</Kbd>
<span>Click to drilldown</span>
</div>
)}
<div className={Styles.hint} data-active="false">
<Kbd>
<MousePointerClick size={12} />
</Kbd>
<span>Click to drilldown</span>
</div>
<div className={Styles.hint} data-active="false">
<span>Press</span>
<Kbd>{pinKey.toUpperCase()}</Kbd>

View File

@@ -11,10 +11,11 @@
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
margin-bottom: var(--spacing-2);
}
.pinnedItem {
padding: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-4) 0 var(--spacing-4);
}
.status {

View File

@@ -1,13 +1,9 @@
.container {
padding-bottom: var(--spacing-6);
}
.list {
width: 100%;
:global(div[data-viewport-type='element']) {
left: 0;
box-sizing: border-box;
padding: var(--spacing-4) var(--spacing-2) var(--spacing-4) var(--spacing-4);
padding: 0px var(--spacing-2) 0 var(--spacing-4);
[data-test-id='virtuoso-item-list'] > * + * {
margin-top: var(--spacing-2);

View File

@@ -9,18 +9,17 @@ import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import Styles from './TooltipList.module.scss';
import { getAbsoluteUrl } from 'utils/basePath';
// Fallback per-item height before Virtuoso reports the real total.
const TOOLTIP_ITEM_HEIGHT = 38;
const LIST_MAX_HEIGHT = 300;
interface TooltipListProps {
id: string;
content: TooltipContentItem[];
}
export default function TooltipList({
id,
content,
}: TooltipListProps): JSX.Element {
const isDarkMode = useIsDarkMode();
@@ -42,25 +41,23 @@ export default function TooltipList({
if (!isScrollEventTriggered.current) {
// TODO: remove event in July 2026
logEvent(Events.TOOLTIP_CONTENT_SCROLLED, {
id,
path: getAbsoluteUrl(window.location.pathname),
});
isScrollEventTriggered.current = true;
}
}, []);
return (
<div className={Styles.container}>
<Virtuoso
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
data-testid="uplot-tooltip-list"
data={content}
onScroll={handleScroll}
style={{ height }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={item.isHighlighted === true} />
)}
/>
</div>
<Virtuoso
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
data-testid="uplot-tooltip-list"
data={content}
onScroll={handleScroll}
style={{ height }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={false} />
)}
/>
);
}

View File

@@ -2,7 +2,6 @@ import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import uPlot, { AlignedData, Series } from 'uplot';
import { SyncTooltipFilterMode } from '../../plugins/TooltipPlugin/types';
import { TooltipContentItem } from '../types';
export const FALLBACK_SERIES_COLOR = '#000000';
@@ -64,7 +63,6 @@ export function buildTooltipContent({
decimalPrecision,
isStackedBarChart,
syncedSeriesIndexes,
syncFilterMode,
}: {
data: AlignedData;
series: Series[];
@@ -75,16 +73,10 @@ export function buildTooltipContent({
decimalPrecision?: PrecisionOption;
isStackedBarChart?: boolean;
syncedSeriesIndexes?: number[] | null;
syncFilterMode?: SyncTooltipFilterMode;
}): TooltipContentItem[] {
const items: TooltipContentItem[] = [];
const matchedIndexes =
syncedSeriesIndexes != null ? new Set(syncedSeriesIndexes) : null;
const filterMode = syncFilterMode ?? SyncTooltipFilterMode.Filtered;
// In Filtered mode the matched indexes act as a whitelist; in All mode every
// series renders and matched indexes only drive row highlighting.
const allowedIndexes =
filterMode === SyncTooltipFilterMode.All ? null : matchedIndexes;
syncedSeriesIndexes != null ? new Set(syncedSeriesIndexes) : null;
for (let seriesIndex = 1; seriesIndex < series.length; seriesIndex += 1) {
const seriesItem = series[seriesIndex];
@@ -97,7 +89,6 @@ export function buildTooltipContent({
const dataIndex = dataIndexes[seriesIndex];
const isSync = allowedIndexes != null;
const isHighlighted = matchedIndexes?.has(seriesIndex) ?? false;
if (dataIndex === null) {
if (isSync) {
@@ -107,7 +98,6 @@ export function buildTooltipContent({
tooltipValue: 'No Data',
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
isActive: false,
isHighlighted,
});
}
continue;
@@ -128,7 +118,6 @@ export function buildTooltipContent({
tooltipValue: getToolTipValue(baseValue, yAxisUnit, decimalPrecision),
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
isActive: seriesIndex === activeSeriesIndex,
isHighlighted,
});
} else if (isSync) {
items.push({
@@ -137,7 +126,6 @@ export function buildTooltipContent({
tooltipValue: 'No Data',
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
isActive: false,
isHighlighted,
});
}
}

View File

@@ -4,7 +4,6 @@ import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
* Props for the Plot component
@@ -59,31 +58,17 @@ export interface TooltipRenderArgs {
isPinned: boolean;
dismiss: () => void;
viaSync: boolean;
/** In Tooltip sync mode, identifies receiver series that match the source's
* focused series on the shared groupBy keys.
* Filtered mode: limits which series are rendered (null = no filter,
* [] = no matches/tooltip hidden upstream, [...] = allowed indexes).
* All mode: same indexes are interpreted as a highlight set; non-matching
* series still render. */
/** In Tooltip sync mode, limits which series are rendered in the receiver tooltip.
* null = no filtering; [] = no matches (tooltip hidden upstream); [...] = allowed indexes */
syncedSeriesIndexes?: number[] | null;
/** Receiver-side filter mode for the synced tooltip. Defaults to Filtered. */
syncFilterMode?: SyncTooltipFilterMode;
}
export interface IRenderTooltipFooterArgs {
pinKey?: string;
isPinned: boolean;
dismiss: () => void;
}
export interface BaseTooltipProps {
id: string;
showTooltipHeader?: boolean;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => ReactNode;
timezone?: Timezone;
}
@@ -121,9 +106,4 @@ export interface TooltipContentItem {
tooltipValue: string;
color: string;
isActive: boolean;
/** Synced receiver series whose metric matches the source's focused series
* on the shared groupBy keys, in 'all' filter mode. List rendering uses this
* to apply the active highlight to matching rows while non-matching rows
* stay dimmed. */
isHighlighted?: boolean;
}

View File

@@ -18,7 +18,6 @@ import {
import {
DashboardCursorSync,
DEFAULT_PIN_TOOLTIP_KEY,
SyncTooltipFilterMode,
TooltipControllerContext,
TooltipControllerState,
TooltipLayoutInfo,
@@ -33,6 +32,7 @@ import {
import { Events } from 'constants/events';
import Styles from './TooltipPlugin.module.scss';
import { getAbsoluteUrl } from 'utils/basePath';
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
// the plot this avoids flicker when moving between nearby points.
@@ -199,14 +199,10 @@ export default function TooltipPlugin({
if (!controller.hoverActive || !plot) {
return null;
}
const filterMode =
syncMetadata?.filterMode ?? SyncTooltipFilterMode.Filtered;
// In Filtered Tooltip sync mode, suppress the receiver tooltip entirely
// when no receiver series match the source panel's focused series. In
// All mode the tooltip still renders with every series visible.
// In Tooltip sync mode, suppress the receiver tooltip entirely when
// no receiver series match the source panel's focused series.
if (
syncTooltipWithDashboard &&
filterMode === SyncTooltipFilterMode.Filtered &&
controller.cursorDrivenBySync &&
Array.isArray(controller.syncedSeriesIndexes) &&
controller.syncedSeriesIndexes.length === 0
@@ -221,7 +217,6 @@ export default function TooltipPlugin({
dismiss: dismissTooltip,
viaSync: controller.cursorDrivenBySync,
syncedSeriesIndexes: controller.syncedSeriesIndexes,
syncFilterMode: filterMode,
});
}
@@ -309,7 +304,7 @@ export default function TooltipPlugin({
if (event.key === 'Escape') {
if (controller.pinned) {
logEvent(Events.TOOLTIP_UNPINNED, {
id: config.getId(),
path: getAbsoluteUrl(window.location.pathname),
});
dismissTooltip();
}
@@ -323,7 +318,7 @@ export default function TooltipPlugin({
// Toggle off: P pressed while already pinned.
if (controller.pinned) {
logEvent(Events.TOOLTIP_UNPINNED, {
id: config.getId(),
path: getAbsoluteUrl(window.location.pathname),
});
dismissTooltip();
return;
@@ -357,7 +352,7 @@ export default function TooltipPlugin({
controller.clickData = buildClickData(syntheticEvent, plot);
controller.pinned = true;
logEvent(Events.TOOLTIP_PINNED, {
id: config.getId(),
path: getAbsoluteUrl(window.location.pathname),
});
scheduleRender(true);
};

View File

@@ -2,33 +2,10 @@ import uPlot from 'uplot';
import type { ExtendedSeries } from '../../config/types';
import { syncCursorRegistry } from './syncCursorRegistry';
import {
SyncTooltipFilterMode,
type TooltipControllerState,
type TooltipSyncMetadata,
} from './types';
import type { TooltipControllerState, TooltipSyncMetadata } from './types';
/**
* Flattens per-query groupBys into a deduped set of dimension keys.
* A panel's effective groupBy is the union across all of its queries.
*/
function collectGroupByKeys(
groupByPerQuery: TooltipSyncMetadata['groupByPerQuery'],
): Set<string> {
const keys = new Set<string>();
if (!groupByPerQuery) {
return keys;
}
for (const groupBy of Object.values(groupByPerQuery)) {
for (const dim of groupBy) {
keys.add(dim.key);
}
}
return keys;
}
/**
* Returns the dimension keys present in both panels' groupBys.
* Returns the dimension keys present in both groupBy arrays.
* An empty result means no overlap — series highlighting should not run.
*
* exact [A, B] vs [A, B] → [A, B] one match
@@ -37,28 +14,24 @@ function collectGroupByKeys(
* partial [A, B] vs [B, C] → [B]
*/
function getCommonGroupByKeys(
a: TooltipSyncMetadata['groupByPerQuery'],
b: TooltipSyncMetadata['groupByPerQuery'],
a: TooltipSyncMetadata['groupBy'],
b: TooltipSyncMetadata['groupBy'],
): string[] {
const aKeys = collectGroupByKeys(a);
const bKeys = collectGroupByKeys(b);
if (aKeys.size === 0 || bKeys.size === 0) {
if (
!Array.isArray(a) ||
a.length === 0 ||
!Array.isArray(b) ||
b.length === 0
) {
return [];
}
const common: string[] = [];
aKeys.forEach((key) => {
if (bKeys.has(key)) {
common.push(key);
}
});
return common;
const bKeys = new Set(b.map((g) => g.key));
return a.filter((g) => bKeys.has(g.key)).map((g) => g.key);
}
/**
* Returns the 1-based indexes of every visible series whose metric matches
* sourceMetric on all commonKeys. Hidden series (toggled off in the legend)
* are excluded so the synced tooltip is suppressed when no visible series
* would match.
* Returns the 1-based indexes of every series whose metric matches
* sourceMetric on all commonKeys.
*/
function findMatchingSeriesIndexes(
series: uPlot.Series[],
@@ -66,7 +39,7 @@ function findMatchingSeriesIndexes(
commonKeys: string[],
): number[] {
return series.reduce<number[]>((acc, s, i) => {
if (i === 0 || s.show === false) {
if (i === 0) {
return acc;
}
const metric = (s as ExtendedSeries).metric;
@@ -103,15 +76,10 @@ function applySourceSync({
}
/**
* Computes receiver-side series filtering / highlighting for Tooltip sync.
*
* Returns the indexes that the tooltip render path should treat per
* `syncMetadata.filterMode`:
* - Filtered (default): null = no filter, [] = no matches (suppress tooltip),
* number[] = allowed indexes (show only these).
* - All: null = no highlight (show all), number[] = highlight set (show all,
* emphasize matching rows). Never returns [] in this mode so the synced
* tooltip is not suppressed when matches are missing.
* Returns:
* null no groupBy filtering configured or cursor off-chart (no-op for tooltip)
* [] groupBy configured but no receiver series match the source (hide synced tooltip)
* number[] 1-based indexes of matching receiver series (show only these)
*/
function applyReceiverSync({
uPlotInstance,
@@ -131,13 +99,8 @@ function applyReceiverSync({
yCrosshairEl.style.display =
sourceMetadata?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
const filterMode = syncMetadata?.filterMode ?? SyncTooltipFilterMode.Filtered;
const noMatchResult: number[] | null =
filterMode === SyncTooltipFilterMode.All ? null : [];
if (commonKeys.length === 0) {
uPlotInstance.setSeries(null, { focus: false });
return [];
return null;
}
if ((uPlotInstance.cursor.left ?? -1) < 0) {
@@ -148,7 +111,7 @@ function applyReceiverSync({
const sourceSeriesMetric = syncCursorRegistry.getActiveSeriesMetric(syncKey);
if (sourceSeriesMetric == null) {
uPlotInstance.setSeries(null, { focus: false });
return noMatchResult;
return [];
}
const matchingIdxs = findMatchingSeriesIndexes(
@@ -159,7 +122,7 @@ function applyReceiverSync({
if (matchingIdxs.length === 0) {
uPlotInstance.setSeries(null, { focus: false });
return noMatchResult;
return [];
}
uPlotInstance.setSeries(matchingIdxs[0], { focus: true });
@@ -177,7 +140,7 @@ export function createSyncDisplayHook(
// groupBy on both panels is stable (set at config time). Recompute the
// intersection only when the source panel's groupBy reference changes.
let lastSourceGroupBy: TooltipSyncMetadata['groupByPerQuery'];
let lastSourceGroupBy: TooltipSyncMetadata['groupBy'];
let cachedCommonKeys: string[] = [];
return (u: uPlot): void => {
@@ -202,11 +165,11 @@ export function createSyncDisplayHook(
// inside applyReceiverSync.
const sourceMetadata = syncCursorRegistry.getMetadata(syncKey);
if (sourceMetadata?.groupByPerQuery !== lastSourceGroupBy) {
lastSourceGroupBy = sourceMetadata?.groupByPerQuery;
if (sourceMetadata?.groupBy !== lastSourceGroupBy) {
lastSourceGroupBy = sourceMetadata?.groupBy;
cachedCommonKeys = getCommonGroupByKeys(
sourceMetadata?.groupByPerQuery,
syncMetadata?.groupByPerQuery,
sourceMetadata?.groupBy,
syncMetadata?.groupBy,
);
}

View File

@@ -16,18 +16,9 @@ export const TOOLTIP_OFFSET = 10;
export const DEFAULT_PIN_TOOLTIP_KEY = 'p';
export enum DashboardCursorSync {
Crosshair = 'crosshair',
None = 'none',
Tooltip = 'tooltip',
}
/**
* Controls whether a synced tooltip filters series by groupBy intersection
* or shows every series with the matching ones highlighted.
*/
export enum SyncTooltipFilterMode {
Filtered = 'filtered',
All = 'all',
Crosshair,
None,
Tooltip,
}
export interface TooltipViewState {
@@ -49,8 +40,7 @@ export interface TooltipLayoutInfo {
export interface TooltipSyncMetadata {
yAxisUnit?: string;
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
filterMode?: SyncTooltipFilterMode;
groupBy?: BaseAutocompleteData[];
}
export interface TooltipPluginProps {

View File

@@ -1,152 +0,0 @@
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type uPlot from 'uplot';
import type { ExtendedSeries } from '../../config/types';
import { syncCursorRegistry } from '../TooltipPlugin/syncCursorRegistry';
import { createSyncDisplayHook } from '../TooltipPlugin/syncDisplayHook';
import type {
TooltipControllerState,
TooltipSyncMetadata,
} from '../TooltipPlugin/types';
const SYNC_KEY = 'test-sync';
function makeController(): TooltipControllerState {
return {
plot: null,
hoverActive: false,
isAnySeriesActive: false,
pinned: false,
clickData: null,
style: {},
horizontalOffset: 0,
verticalOffset: 0,
seriesIndexes: [],
focusedSeriesIndex: null,
syncedSeriesIndexes: null,
cursorDrivenBySync: false,
plotWithinViewport: true,
windowWidth: 1024,
windowHeight: 768,
pendingPinnedUpdate: false,
};
}
function makeFakePlot(
series: ExtendedSeries[],
cursorEvent: Record<string, unknown> | null = null,
): uPlot {
const root = document.createElement('div');
const yCrosshair = document.createElement('div');
yCrosshair.className = 'u-cursor-y';
root.appendChild(yCrosshair);
return {
root,
series,
cursor: { event: cursorEvent, left: 50 },
setSeries: jest.fn(),
} as unknown as uPlot;
}
const SERVICE_NAME_KEY: BaseAutocompleteData = {
key: 'service.name',
type: 'tag',
};
const groupByService: TooltipSyncMetadata = {
groupByPerQuery: { queryName: [SERVICE_NAME_KEY] },
};
function seedSourcePanel(activeMetric: Record<string, string>): void {
syncCursorRegistry.setMetadata(SYNC_KEY, groupByService);
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, activeMetric);
}
function makeReceiverSeries(
entries: { name: string; show?: boolean }[],
): ExtendedSeries[] {
return [
{} as ExtendedSeries,
...entries.map(
(e) =>
({
show: e.show ?? true,
metric: { 'service.name': e.name },
}) as unknown as ExtendedSeries,
),
];
}
describe('createSyncDisplayHook (receiver-side filtering)', () => {
beforeEach(() => {
syncCursorRegistry.setMetadata(SYNC_KEY, undefined);
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, null);
});
it('returns indexes of visible matching series only', () => {
seedSourcePanel({ 'service.name': 'flagd' });
const series = makeReceiverSeries([
{ name: 'flagd', show: true },
{ name: 'frontend', show: true },
{ name: 'flagd', show: true },
]);
const plot = makeFakePlot(series, null);
const controller = makeController();
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 3]);
});
it('treats all matching series being hidden as no match → empty array', () => {
seedSourcePanel({ 'service.name': 'frontendproxy' });
const series = makeReceiverSeries([
{ name: 'flagd', show: true },
{ name: 'frontendproxy', show: false },
]);
const plot = makeFakePlot(series, null);
const controller = makeController();
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
expect(plot.setSeries).toHaveBeenCalledWith(null, { focus: false });
});
it('excludes hidden series and keeps the visible matches', () => {
seedSourcePanel({ 'service.name': 'flagd' });
const series = makeReceiverSeries([
{ name: 'flagd', show: false },
{ name: 'frontend', show: true },
{ name: 'flagd', show: true },
]);
const plot = makeFakePlot(series, null);
const controller = makeController();
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toStrictEqual([3]);
// Focuses the first visible match, not the hidden one at index 1.
expect(plot.setSeries).toHaveBeenCalledWith(3, { focus: true });
});
it('returns null (no filtering) when the hook runs on the source panel', () => {
const series = makeReceiverSeries([{ name: 'flagd', show: true }]);
// cursor.event != null marks this invocation as the source panel.
const plot = makeFakePlot(series, { type: 'mousemove' });
const controller = makeController();
controller.focusedSeriesIndex = 1;
(series[1] as ExtendedSeries).metric = { 'service.name': 'flagd' };
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toBeNull();
expect(syncCursorRegistry.getActiveSeriesMetric(SYNC_KEY)).toStrictEqual({
'service.name': 'flagd',
});
});
});

Some files were not shown because too many files have changed in this diff Show More