Compare commits

...

9 Commits

Author SHA1 Message Date
Vinícius Lourenço
7b3c1d8cd3 fix(authz): add missing allowed/deniedPermissions to test mocks
Update UseAuthZResult mocks to include the new `allowed` and
`deniedPermissions` fields. Also fix oxlint warnings in useAuthZ.tsx.
2026-07-01 21:24:28 -03:00
Vinícius Lourenço
892bde5a73 feat(authz): add devtools for authz 2026-07-01 21:17:14 -03:00
Vinícius Lourenço
00f23273cf chore(authz): move components/hooks to lib 2026-07-01 14:51:53 -03:00
Srikanth Chekuri
66f03d5912 fix(metrics): use local table for fingerprint ctes (#11931)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(metrics): use local table for fingerprint ctes

* chore: remove queries file

* chore: update reduced_test.go
2026-07-01 16:14:11 +00:00
Pandey
cf69a05f74 fix(dashboards): expose Source as a string enum in the OpenAPI schema (#11930)
The reflector saw Source's unexported valuer.String field and emitted
type: object. Add a JSONSchema exposer that pins type: string, deriving
the enum values from the existing Enum() method so the list of sources
lives in exactly one place.
2026-07-01 15:46:24 +00:00
Vikrant Gupta
1648fce5b1 chore(authz): delete the deprecated authz apis (#11924)
* chore(authz): delete the deprecated authz apis

* test(authz): rework role integration tests onto the new CRUD APIs

Migrate the role integration suite off the deprecated PATCH endpoints and
onto the current declarative role CRUD APIs (Create/Get/List/Update/Delete
with full transactionGroups).

- role/01_register.py: verify managed roles via GetRole's transactionGroups
  against a golden matrix in testdata/role/managed_role_grants.json (no more
  DB tuple assertions).
- role/02_crud.py (new): custom-role CRUD lifecycle, declarative update,
  validation (naming, invalid verb/type/kind/selector, duplicate, managed
  immutability, delete-with-assignee), and license gating.
- role/03_fga.py: resource FGA allow/deny via declarative grant sets.
- role/02_user.py: deleted; user role-membership is covered by the
  passwordauthn suite.
- serviceaccount/06_fga.py: migrated to declarative grant PUTs.
- fixtures/role.py: pure data helpers + find_role_id fixture; tests make
  their HTTP calls directly.

* test(authz): scope role/SA FGA tests to fine-grained selectors

- role FGA: grant read/update/delete on a specific role name (not "*") and
  assert allowed-on-granted vs forbidden-on-other; create is collection-scoped;
  list on "*" returns every role.
- serviceaccount FGA: grant on a specific SA id (with a second SA to prove
  cross-instance denial); dual attach/detach scoped to SA id + role name.
- add create_role fixture (alongside find_role_id) for happy-path role creation;
  validation/failure cases stay inline.
- underscore-prefix file-local constants in both FGA modules.

* test(authz): rename grants terminology to transactions in role tests
2026-07-01 13:11:55 +00:00
Abhi kumar
f93a70884a feat(dashboards-v2): create alerts from a dashboard panel (#11879)
* feat(dashboards-v2): create alerts from a dashboard panel

Wire the panel actions menu's "Create Alerts" item to seed a new alert
from the panel's query. `buildCreateAlertUrl` translates the panel's V5
queries into the V1 compositeQuery the alert page reads (tagged with the
panel type, v5 version and a dashboards source), and
`useCreateAlertFromPanel` opens /alerts/new in a new tab and logs the
action. Available regardless of edit access (V1 parity: create-alert
works on locked dashboards too).

To reach the query, the full panel is threaded through
Panel -> PanelHeader -> PanelActionsMenu -> usePanelActionItems instead
of just its kind; the header now derives its name/description from the
panel as well.

* feat(dashboards-v2): add a Create alert rule action to the panel editor

Add an "Actions" group at the foot of the editor config pane — a list of
cross-page navigation links kept distinct from the collapsible config
sections above. The first action, "Create alert", reuses
useCreateAlertFromPanel to seed an alert from the draft query; the group
hides for kinds that can't seed an alert and scales to more actions.

ConfigPane now derives the panel kind from its spec and takes the draft
panel + panelId so the group can build the link.

* refactor(dashboards-v2): address review feedback on panel create-alert

- buildCreateAlertUrl: drop redundant `?? []` (spec.queries is a required array)
- buildCreateAlertUrl: extract FormattablePluginSpec type for the formatting cast
- usePanelActionItems: rename kindActions -> panelCapabilities (its type is
  PanelActionCapabilities; avoids the clash with the panelActions prop)
- useCreateAlerts (V1): mark @deprecated pointing at the V2 create-alert path

* chore: pr review changes
2026-07-01 11:31:15 +00:00
Srikanth Chekuri
e1c586e0dc chore(metrics): review follow ups for volume control (#11887)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore(metrics): review follow ups for volume control

* chore: the reduced metrics show up in summary page

* chore: 1h; 6h window changes

* chore: address some gaps

* chore: asset warning gap

* chore: address lint

* chore: regenerate api
2026-07-01 11:04:41 +00:00
Vikrant Gupta
984b2d0138 fix(authz): drop the organization grant tuples (#11922)
* fix(authz): stop seeding organization role tuples and remove existing ones

Fetching the signoz-admin role via GET /api/v1/roles/{id} panicked with
"invalid input format: organization:organization/*". Organization objects
use a 2-part id (organization:organization/<selector>) that
MustNewObjectFromString cannot parse, unlike the 4-part format every other
resource emits.

The signoz-admin managed role granted read/update on the organization
resource, so those tuples were read back and crashed the parser. These
grants are dead weight: org routes are gated by admin role membership, not
by the organization object.

Remove the organization transactions from the signoz-admin seed and add
migration 098 to delete existing organization tuples and changelog rows.

* fix(authz): parse organization object string in MustNewObjectFromString

The organization resource is the root entity and encodes its object as
"organization:organization/<selector>", without the orgID and kind
segments every other resource uses. The 4-part parser panicked with
"invalid input format" when it encountered this shape.

Detect the type first, then handle the organization 2-part format
explicitly, mirroring resourceOrganization.Object(). All other resources
keep the existing 4-part path. This makes listing a role's permissions
robust to organization tuples regardless of how they were created.
2026-07-01 09:26:53 +00:00
201 changed files with 6180 additions and 3138 deletions

5
.github/CODEOWNERS vendored
View File

@@ -109,10 +109,7 @@ go.mod @therealpandey
/pkg/modules/role/ @therealpandey
/pkg/types/coretypes/ @therealpandey @vikrantgupta25
/frontend/src/hooks/useAuthZ/ @H4ad
/frontend/src/components/GuardAuthZ/ @H4ad
/frontend/src/components/AuthZTooltip/ @H4ad
/frontend/src/components/createGuardedRoute/ @H4ad
/frontend/src/lib/authz/ @H4ad
/frontend/src/container/RolesSettings/ @H4ad
/frontend/src/components/RolesSelect/ @H4ad
/frontend/src/pages/MembersSettings/ @H4ad

View File

@@ -12,7 +12,7 @@ import (
"github.com/spf13/cobra"
)
const permissionsTypePath = "frontend/src/hooks/useAuthZ/permissions.type.ts"
const permissionsTypePath = "frontend/src/lib/authz/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

View File

@@ -618,13 +618,6 @@ components:
provider:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesPatchableRole:
properties:
description:
type: string
required:
- description
type: object
AuthtypesPostableAuthDomain:
properties:
config:
@@ -2536,22 +2529,6 @@ components:
- 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:
@@ -2737,6 +2714,14 @@ components:
type: string
dashboardName:
type: string
filterBy:
items:
type: string
type: array
groupBy:
items:
type: string
type: array
panelId:
type: string
panelName:
@@ -3586,7 +3571,7 @@ components:
- user
- system
- integration
type: object
type: string
DashboardtypesSpanGaps:
properties:
fillLessThan:
@@ -5412,6 +5397,9 @@ components:
type: string
id:
type: string
ingestedSamples:
minimum: 0
type: integer
ingestedSeries:
minimum: 0
type: integer
@@ -5424,9 +5412,9 @@ components:
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
metricName:
type: string
reductionPercent:
format: double
type: number
retainedSamples:
minimum: 0
type: integer
retainedSeries:
minimum: 0
type: integer
@@ -5444,7 +5432,8 @@ components:
- active
- ingestedSeries
- retainedSeries
- reductionPercent
- ingestedSamples
- retainedSamples
type: object
MetricreductionruletypesGettableReductionRulePreview:
properties:
@@ -5487,15 +5476,23 @@ components:
estimatedMonthlySavingsUsd:
format: double
type: number
ingestedSamples:
minimum: 0
type: integer
ingestedSeries:
minimum: 0
type: integer
retainedSamples:
minimum: 0
type: integer
retainedSeries:
minimum: 0
type: integer
required:
- ingestedSeries
- retainedSeries
- ingestedSamples
- retainedSamples
- estimatedMonthlySavingsUsd
type: object
MetricreductionruletypesGettableReductionRules:
@@ -5561,7 +5558,6 @@ components:
- metric
- ingested_volume
- reduced_volume
- reduction
- last_updated
type: string
MetricreductionruletypesUpdatableReductionRule:
@@ -11825,68 +11821,6 @@ paths:
summary: Get role
tags:
- role
patch:
deprecated: true
description: This endpoint patches a role
operationId: PatchRole
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPatchableRole'
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:update
- tokenizer:
- role:update
summary: Patch role
tags:
- role
put:
deprecated: false
description: This endpoint updates a role
@@ -11949,158 +11883,6 @@ paths:
summary: Update role
tags:
- role
/api/v1/roles/{id}/relations/{relation}/objects:
get:
deprecated: false
description: Gets all objects connected to the specified role via a given relation
type
operationId: GetObjects
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: relation
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:read
- tokenizer:
- role:read
summary: Get objects for a role by relation
tags:
- role
patch:
deprecated: true
description: Patches the objects connected to the specified role via a given
relation type
operationId: PatchObjects
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: relation
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CoretypesPatchableObjects'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:update
- tokenizer:
- role:update
summary: Patch objects for a role by relation
tags:
- role
/api/v1/route_policies:
get:
deprecated: false

View File

@@ -260,40 +260,6 @@ func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID va
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
storableRole, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
objects := make([]*coretypes.Object, 0)
for _, objectType := range provider.registry.Types() {
if coretypes.ErrIfVerbNotValidForType(relation.Verb, objectType) != nil {
continue
}
resourceObjects, err := provider.
ListObjects(
ctx,
authtypes.MustNewSubject(coretypes.NewResourceRole(), storableRole.Name, orgID, &coretypes.VerbAssignee),
relation,
objectType,
)
if err != nil {
return nil, err
}
objects = append(objects, resourceObjects...)
}
return objects, nil
}
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
@@ -324,39 +290,6 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
return provider.store.Update(ctx, orgID, updatedRole.Role)
}
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
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 {
_, 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())
}
additionTuples, err := authtypes.GetAdditionTuples(name, orgID, relation, additions)
if err != nil {
return err
}
deletionTuples, err := authtypes.GetDeletionTuples(name, orgID, relation, deletions)
if err != nil {
return err
}
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {
return err
}
return nil
}
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {

View File

@@ -286,7 +286,7 @@ func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID
return module.pkgDashboardModule.Get(ctx, orgID, id)
}
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error) {
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
}

View File

@@ -22,6 +22,8 @@ var (
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
const sampleBucketExpr = "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalMinute(10)))) * 1000 AS bucket"
type volumeRow struct {
MetricName string
Ingested uint64
@@ -289,12 +291,9 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
}
ctx = c.withThreads(ctx)
orderExpr := "ingested"
switch orderBy {
case metricreductionruletypes.OrderByReducedVolume:
orderExpr = "reduced"
case metricreductionruletypes.OrderByReduction:
orderExpr = "if(ingested = 0, 0, (toFloat64(ingested) - toFloat64(reduced)) / toFloat64(ingested))"
orderExpr := "ifNull(i.samples, 0)"
if orderBy == metricreductionruletypes.OrderByReducedVolume {
orderExpr = "if(ifNull(d.samples, 0) = 0 OR ifNull(d.samples, 0) > ifNull(i.samples, 0), ifNull(i.samples, 0), ifNull(d.samples, 0))"
}
direction := "ASC"
if order == metricreductionruletypes.OrderDesc {
@@ -310,17 +309,17 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
sb.From("(SELECT arrayJoin(" + sb.Var(metricNames) + ") AS metric_name) AS base")
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, uniq(fingerprint) AS cnt FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
"(SELECT metric_name, uniq(fingerprint) AS cnt, count() AS samples FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
"base.metric_name = i.metric_name",
)
// Reduced series are spread across two type-specific tables; union the per-table distinct
// reduced_fingerprints and sum per metric (a metric only lands in the table matching its type).
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, sum(cnt) AS cnt FROM ("+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
"(SELECT metric_name, sum(cnt) AS cnt, sum(samples) AS samples FROM ("+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt, uniq(reduced_fingerprint, unix_milli) AS samples FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
" UNION ALL "+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt, uniq(reduced_fingerprint, unix_milli) AS samples FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
") GROUP BY metric_name) AS d",
"base.metric_name = d.metric_name",
)
@@ -347,122 +346,186 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
return out, rows.Err()
}
func (c *clickhouse) SampleVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, uint64, error) {
func (c *clickhouse) SampleVolumeByMetric(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]volumeRow, error) {
if len(metricNames) == 0 {
return 0, 0, nil
return map[string]volumeRow{}, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.countRawSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
last, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
sum, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
return ingested, min(last+sum, ingested), nil
}
func (c *clickhouse) countRawSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("count()")
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var count uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested samples")
}
return count, nil
}
func (c *clickhouse) countReducedSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
// Reduced tables key the series on reduced_fingerprint (not fingerprint); dedupe ReplacingMergeTree recomputes.
sb.Select("uniq(reduced_fingerprint, unix_milli)")
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var count uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced samples")
}
return count, nil
}
// SeriesTimeseries returns ingested vs reduced series per 60s bucket from the samples tables, gated
// to each metric's strict effective_from (see strictEffectiveFrom).
func (c *clickhouse) SeriesTimeseries(ctx context.Context, allMetrics, reducedMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
if len(allMetrics) == 0 {
return []volumePoint{}, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.ingestedSeriesByBucket(ctx, allMetrics, effectiveFrom, startMs, endMs)
ingested, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
retained := make(map[int64]uint64)
if len(reducedMetrics) > 0 {
reduced, err := c.reducedSeriesByBucket(ctx, reducedMetrics, effectiveFrom, startMs, endMs)
last, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
sum, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
out := make(map[string]volumeRow, len(metricNames))
for _, name := range metricNames {
out[name] = volumeRow{MetricName: name, Ingested: ingested[name], Reduced: last[name] + sum[name]}
}
return out, nil
}
func (c *clickhouse) countSamplesByMetric(ctx context.Context, table, countExpr string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", countExpr)
sb.From(table)
conds := []string{
sb.In("metric_name", names...),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("metric_name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count samples")
}
defer rows.Close()
out := make(map[string]uint64, len(metricNames))
for rows.Next() {
var (
metricName string
count uint64
)
if err := rows.Scan(&metricName, &count); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
}
out[metricName] = count
}
return out, rows.Err()
}
func (c *clickhouse) TotalVolume(ctx context.Context, startMs, endMs int64) (uint64, uint64, error) {
ctx = c.withThreads(ctx)
sb := sqlbuilder.NewSelectBuilder()
sb.Select("uniq(fingerprint)", "count()")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var series, samples uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&series, &samples); err != nil {
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count total ingested volume")
}
return series, samples, nil
}
func (c *clickhouse) SampleTimeseries(ctx context.Context, ruledMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
ctx = c.withThreads(ctx)
ingested, err := c.totalSamplesByBucket(ctx, startMs, endMs)
if err != nil {
return nil, err
}
ruledIngested := make(map[int64]uint64)
ruledRetained := make(map[int64]uint64)
if len(ruledMetrics) > 0 {
ruledIngested, err = c.ruledIngestedSamplesByBucket(ctx, ruledMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range reduced {
retained[ts] += count
}
}
reducedSet := make(map[string]struct{}, len(reducedMetrics))
for _, name := range reducedMetrics {
reducedSet[name] = struct{}{}
}
nonReduced := make([]string, 0, len(allMetrics))
for _, name := range allMetrics {
if _, ok := reducedSet[name]; !ok {
nonReduced = append(nonReduced, name)
}
}
if len(nonReduced) > 0 {
nonReducedIngested, err := c.ingestedSeriesByBucket(ctx, nonReduced, effectiveFrom, startMs, endMs)
ruledRetained, err = c.ruledRetainedSamplesByBucket(ctx, ruledMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range nonReducedIngested {
retained[ts] += count
}
retained := make(map[int64]uint64, len(ingested))
for ts, total := range ingested {
shed := uint64(0)
if ri := ruledIngested[ts]; ri > ruledRetained[ts] {
shed = ri - ruledRetained[ts]
}
if total > shed {
retained[ts] = total - shed
} else {
retained[ts] = 0
}
}
return mergeVolumePoints(ingested, retained), nil
}
func (c *clickhouse) totalSamplesByBucket(ctx context.Context, startMs, endMs int64) (map[int64]uint64, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(sampleBucketExpr, "count()")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
sb.GroupBy("bucket")
return c.scanBuckets(ctx, sb)
}
func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select(sampleBucketExpr, "count()")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
return c.scanBuckets(ctx, sb)
}
// reduced 60s rows are versioned by computed_at, so count distinct buckets.
func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
out := make(map[int64]uint64)
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select(sampleBucketExpr, "uniq(reduced_fingerprint, unix_milli)")
sb.From(telemetrymetrics.DBName + "." + table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
counts, err := c.scanBuckets(ctx, sb)
if err != nil {
return nil, err
}
for ts, count := range counts {
out[ts] += count
}
}
return out, nil
}
func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
buckets := make(map[int64]struct{}, len(ingested))
for ts := range ingested {
@@ -488,60 +551,6 @@ func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
return points
}
// ingestedSeriesByBucket counts distinct raw fingerprints per hourly bucket from the samples buffer.
func (c *clickhouse) ingestedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(fingerprint)")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
return c.scanBuckets(ctx, sb)
}
// reducedSeriesByBucket counts distinct reduced_fingerprints per hourly bucket, summed across the two
// reduced sample tables (a metric only lands in the table matching its type, so per-bucket sums are
// exact).
func (c *clickhouse) reducedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
out := make(map[int64]uint64)
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(reduced_fingerprint)")
sb.From(telemetrymetrics.DBName + "." + table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
counts, err := c.scanBuckets(ctx, sb)
if err != nil {
return nil, err
}
for ts, count := range counts {
out[ts] += count
}
}
return out, nil
}
func (c *clickhouse) scanBuckets(ctx context.Context, sb *sqlbuilder.SelectBuilder) (map[int64]uint64, error) {
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)

View File

@@ -4,7 +4,6 @@ import (
"context"
"log/slog"
"sort"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -28,9 +27,16 @@ import (
const (
// effectiveFromMargin delays effective_from so the collector picks up the synced rule before it
// goes live; it must be >= the collector's rule-refresh interval (see signoz-otel-collector#839).
effectiveFromMargin = 5 * time.Minute
defaultPreviewLookback = 24 * time.Hour
// goes live; it must be >= the collector's rule-refresh interval (~2m worst case,
// see signoz-otel-collector#839).
effectiveFromMargin = 2 * time.Minute
// uiActivationDelay keeps a rule shown as "pending" in the UI for a while after it goes live to
// the collector, so the user doesn't see "active" before reduced data is actually flowing. The
// user-facing pending window is effectiveFromMargin + uiActivationDelay (~5m).
uiActivationDelay = 3 * time.Minute
defaultPreviewLookback = 1 * time.Hour
statsLookback = 1 * time.Hour
timeseriesLookback = 6 * time.Hour
pricePerMillionSamplesUSD = 0.1
monthDuration = 30 * 24 * time.Hour
@@ -80,7 +86,7 @@ func (m *module) List(ctx context.Context, orgID valuer.UUID, params *metricredu
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
startMs := now.Add(-statsLookback).UnixMilli()
endMs := now.UnixMilli()
switch params.OrderBy {
@@ -107,10 +113,14 @@ func (m *module) listSortedByColumn(ctx context.Context, orgID valuer.UUID, para
if err != nil {
return nil, err
}
sampleVolumes, err := m.ch.SampleVolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(domainRules))
for _, rule := range domainRules {
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName]))
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName], sampleVolumes[rule.MetricName]))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
@@ -139,13 +149,24 @@ func (m *module) listSortedByVolume(ctx context.Context, orgID valuer.UUID, para
return nil, err
}
pageMetricNames := make([]string, 0, len(ranked))
for _, row := range ranked {
pageMetricNames = append(pageMetricNames, row.MetricName)
}
// TODO(srikanthccv): do we need to run this query? can we just get the same from RankByVolume?
sampleVolumes, err := m.ch.SampleVolumeByMetric(ctx, pageMetricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(ranked))
for _, row := range ranked {
rule, ok := ruleByMetric[row.MetricName]
if !ok {
continue
}
rules = append(rules, withVolume(toGettableReductionRule(rule), row))
rules = append(rules, withVolume(toGettableReductionRule(rule), row, sampleVolumes[row.MetricName]))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
@@ -288,20 +309,17 @@ func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreduction
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
startMs := now.Add(-statsLookback).UnixMilli()
endMs := now.UnixMilli()
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
rules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
if err != nil {
return nil, err
}
if total == 0 {
return &metricreductionruletypes.GettableReductionRuleStats{}, nil
}
metricNames := make([]string, len(allRules))
effectiveFrom := make(map[string]int64, len(allRules))
for i, rule := range allRules {
metricNames := make([]string, len(rules))
effectiveFrom := make(map[string]int64, len(rules))
for i, rule := range rules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
@@ -310,31 +328,43 @@ func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreduction
if err != nil {
return nil, err
}
var ingestedSeries, retainedSeries uint64
reducedMetricNames := make([]string, 0, len(volumes))
reducedEffectiveFrom := make(map[string]int64, len(volumes))
for name, volume := range volumes {
ingestedSeries += volume.Ingested
retained := effectiveRetained(volume.Ingested, volume.Reduced)
retainedSeries += retained
if retained < volume.Ingested {
reducedMetricNames = append(reducedMetricNames, name)
reducedEffectiveFrom[name] = effectiveFrom[name]
}
var ruledIngestedSeries, ruledRetainedSeries uint64
for _, volume := range volumes {
ruledIngestedSeries += volume.Ingested
ruledRetainedSeries += effectiveRetained(volume.Ingested, volume.Reduced)
}
ingestedSamples, reducedSamples, err := m.ch.SampleVolume(ctx, reducedMetricNames, reducedEffectiveFrom, startMs, endMs)
sampleVolumes, err := m.ch.SampleVolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
var ruledIngestedSamples, ruledRetainedSamples uint64
for _, sv := range sampleVolumes {
ruledIngestedSamples += sv.Ingested
ruledRetainedSamples += effectiveRetained(sv.Ingested, sv.Reduced)
}
totalSeries, totalSamples, err := m.ch.TotalVolume(ctx, startMs, endMs)
if err != nil {
return nil, err
}
return &metricreductionruletypes.GettableReductionRuleStats{
IngestedSeries: ingestedSeries,
RetainedSeries: retainedSeries,
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ingestedSamples, reducedSamples, startMs, endMs),
IngestedSeries: totalSeries,
RetainedSeries: clampSub(totalSeries, ruledIngestedSeries-ruledRetainedSeries),
IngestedSamples: totalSamples,
RetainedSamples: clampSub(totalSamples, ruledIngestedSamples-ruledRetainedSamples),
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ruledIngestedSamples, ruledRetainedSamples, startMs, endMs),
}, nil
}
func clampSub(a, b uint64) uint64 {
if a < b {
return 0
}
return a - b
}
// monthlySavingsUSD extrapolates the windowed sample reduction to a monthly figure at the per-sample
// list price. Ingested is gated to effective_from upstream, so pre-activation hours don't inflate it.
func monthlySavingsUSD(ingestedSamples, reducedSamples uint64, startMs, endMs int64) float64 {
@@ -352,7 +382,7 @@ func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuild
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
startMs := now.Add(-timeseriesLookback).UnixMilli()
endMs := now.UnixMilli()
allRules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
@@ -366,18 +396,7 @@ func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuild
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
reducedNames := make([]string, 0, len(volumes))
for name, volume := range volumes {
if effectiveRetained(volume.Ingested, volume.Reduced) < volume.Ingested {
reducedNames = append(reducedNames, name)
}
}
points, err := m.ch.SeriesTimeseries(ctx, metricNames, reducedNames, effectiveFrom, startMs, endMs)
points, err := m.ch.SampleTimeseries(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
@@ -414,7 +433,7 @@ func buildVolumeTimeseries(points []volumePoint) *querybuildertypesv5.QueryRange
}
func (m *module) validateMetricForReduction(ctx context.Context, orgID valuer.UUID, metricName string) error {
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, metricName)
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, orgID, metricName)
if err != nil {
return err
}
@@ -447,12 +466,12 @@ func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metr
m.logger.WarnContext(ctx, "failed to fetch related dashboards for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
} else {
for _, item := range dashboards[metricName] {
usedLabels := append(splitCSV(item["group_by"]), splitCSV(item["filter_by"])...)
usedLabels := append(append([]string{}, item.GroupBy...), item.FilterBy...)
affected = append(affected, metricreductionruletypes.AffectedAsset{
Type: metricreductionruletypes.AssetTypeDashboard,
ID: item["dashboard_id"],
Name: item["dashboard_name"],
Widget: &metricreductionruletypes.AffectedWidget{ID: item["widget_id"], Name: item["widget_name"]},
ID: item.DashboardID,
Name: item.DashboardName,
Widget: &metricreductionruletypes.AffectedWidget{ID: item.PanelID, Name: item.PanelName},
ImpactedLabels: intersectLabels(usedLabels, droppedSet),
})
}
@@ -482,7 +501,7 @@ func toGettableReductionRule(rule *metricreductionruletypes.ReductionRule) metri
MatchType: rule.MatchType,
Labels: rule.Labels,
EffectiveFrom: rule.EffectiveFrom,
Active: !rule.EffectiveFrom.After(time.Now()),
Active: !rule.EffectiveFrom.Add(uiActivationDelay).After(time.Now()),
}
}
@@ -493,12 +512,11 @@ func effectiveRetained(ingested, reduced uint64) uint64 {
return reduced
}
func withVolume(rule metricreductionruletypes.GettableReductionRule, volume volumeRow) metricreductionruletypes.GettableReductionRule {
rule.IngestedSeries = volume.Ingested
rule.RetainedSeries = effectiveRetained(volume.Ingested, volume.Reduced)
if volume.Ingested > 0 {
rule.ReductionPercent = (1 - float64(rule.RetainedSeries)/float64(volume.Ingested)) * 100
}
func withVolume(rule metricreductionruletypes.GettableReductionRule, series volumeRow, samples volumeRow) metricreductionruletypes.GettableReductionRule {
rule.IngestedSeries = series.Ingested
rule.RetainedSeries = effectiveRetained(series.Ingested, series.Reduced)
rule.IngestedSamples = samples.Ingested
rule.RetainedSamples = effectiveRetained(samples.Ingested, samples.Reduced)
return rule
}
@@ -518,13 +536,6 @@ func intersectLabels(keys []string, droppedSet map[string]struct{}) []string {
return out
}
func splitCSV(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, ",")
}
func resolveDroppedKept(matchType metricreductionruletypes.MatchType, ruleLabels, keys []string) (dropped, kept []string) {
ruleSet := make(map[string]struct{}, len(ruleLabels))
for _, l := range ruleLabels {

View File

@@ -35,7 +35,7 @@ func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreduc
Where("org_id = ?", orgID).
Order(column + " " + direction)
if params.Search != "" {
query = query.Where("metric_name LIKE ?", "%"+params.Search+"%")
query = query.Where("metric_name LIKE ? ESCAPE '\\'", "%"+s.sqlstore.Formatter().EscapeLikePattern(params.Search)+"%")
}
if params.MetricName != "" {
query = query.Where("metric_name = ?", params.MetricName)

View File

@@ -64,6 +64,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
signoz.Prometheus,
signoz.TelemetryStore.Cluster(),
signoz.Cache,
signoz.Flagger,
nil,
)

View File

@@ -0,0 +1,3 @@
export const IS_DEV = false;
export const IS_PROD = true;
export const MODE = 'test';

View File

@@ -29,6 +29,7 @@ const config: Config.InitialOptions = {
'^constants/env$': '<rootDir>/__mocks__/env.ts',
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',
'^lib/env$': '<rootDir>/__mocks__/lib/env.ts',
'^test-mocks/(.*)$': '<rootDir>/__mocks__/$1',
'^react-syntax-highlighter/dist/esm/(.*)$':
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',

View File

@@ -432,6 +432,9 @@ importers:
'@typescript/native-preview':
specifier: 7.0.0-dev.20260430.1
version: 7.0.0-dev.20260430.1
babel-plugin-transform-import-meta:
specifier: ^2.3.3
version: 2.3.3(@babel/core@7.29.0)
eslint-plugin-sonarjs:
specifier: 4.0.2
version: 4.0.2(eslint@10.2.1(jiti@2.6.1))
@@ -4089,6 +4092,11 @@ packages:
babel-plugin-syntax-jsx@6.18.0:
resolution: {integrity: sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==}
babel-plugin-transform-import-meta@2.3.3:
resolution: {integrity: sha512-bbh30qz1m6ZU1ybJoNOhA2zaDvmeXMnGNBMVMDOJ1Fni4+wMBoy/j7MTRVmqAUCIcy54/rEnr9VEBsfcgbpm3Q==}
peerDependencies:
'@babel/core': ^7.10.0
babel-preset-current-node-syntax@1.2.0:
resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==}
peerDependencies:
@@ -12997,6 +13005,12 @@ snapshots:
babel-plugin-syntax-jsx@6.18.0: {}
babel-plugin-transform-import-meta@2.3.3(@babel/core@7.29.0):
dependencies:
'@babel/core': 7.29.0
'@babel/template': 7.28.6
tslib: 2.8.1
babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0):
dependencies:
'@babel/core': 7.29.0

View File

@@ -18,19 +18,13 @@ import type {
} from 'react-query';
import type {
AuthtypesPatchableRoleDTO,
AuthtypesPostableRoleDTO,
AuthtypesUpdatableRoleDTO,
CoretypesPatchableObjectsDTO,
CreateRole201,
DeleteRolePathParameters,
GetObjects200,
GetObjectsPathParameters,
GetRole200,
GetRolePathParameters,
ListRoles200,
PatchObjectsPathParameters,
PatchRolePathParameters,
RenderErrorResponseDTO,
UpdateRolePathParameters,
} from '../sigNoz.schemas';
@@ -365,107 +359,6 @@ export const invalidateGetRole = async (
return queryClient;
};
/**
* This endpoint patches a role
* @deprecated
* @summary Patch role
*/
export const patchRole = (
{ id }: PatchRolePathParameters,
authtypesPatchableRoleDTO?: BodyType<AuthtypesPatchableRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: authtypesPatchableRoleDTO,
signal,
});
};
export const getPatchRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
> => {
const mutationKey = ['patchRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof patchRole>>,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchRole(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof patchRole>>
>;
export type PatchRoleMutationBody =
| BodyType<AuthtypesPatchableRoleDTO>
| undefined;
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch role
*/
export const usePatchRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
> => {
return useMutation(getPatchRoleMutationOptions(options));
};
/**
* This endpoint updates a role
* @summary Update role
@@ -565,205 +458,3 @@ export const useUpdateRole = <
> => {
return useMutation(getUpdateRoleMutationOptions(options));
};
/**
* Gets all objects connected to the specified role via a given relation type
* @summary Get objects for a role by relation
*/
export const getObjects = (
{ id, relation }: GetObjectsPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetObjects200>({
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
method: 'GET',
signal,
});
};
export const getGetObjectsQueryKey = ({
id,
relation,
}: GetObjectsPathParameters) => {
return [`/api/v1/roles/${id}/relations/${relation}/objects`] as const;
};
export const getGetObjectsQueryOptions = <
TData = Awaited<ReturnType<typeof getObjects>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, relation }: GetObjectsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getObjects>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetObjectsQueryKey({ id, relation });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getObjects>>> = ({
signal,
}) => getObjects({ id, relation }, signal);
return {
queryKey,
queryFn,
enabled: !!(id && relation),
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getObjects>>, TError, TData> & {
queryKey: QueryKey;
};
};
export type GetObjectsQueryResult = NonNullable<
Awaited<ReturnType<typeof getObjects>>
>;
export type GetObjectsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get objects for a role by relation
*/
export function useGetObjects<
TData = Awaited<ReturnType<typeof getObjects>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, relation }: GetObjectsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getObjects>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetObjectsQueryOptions({ id, relation }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get objects for a role by relation
*/
export const invalidateGetObjects = async (
queryClient: QueryClient,
{ id, relation }: GetObjectsPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetObjectsQueryKey({ id, relation }) },
options,
);
return queryClient;
};
/**
* Patches the objects connected to the specified role via a given relation type
* @deprecated
* @summary Patch objects for a role by relation
*/
export const patchObjects = (
{ id, relation }: PatchObjectsPathParameters,
coretypesPatchableObjectsDTO?: BodyType<CoretypesPatchableObjectsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: coretypesPatchableObjectsDTO,
signal,
});
};
export const getPatchObjectsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchObjects>>,
TError,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchObjects>>,
TError,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
> => {
const mutationKey = ['patchObjects'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof patchObjects>>,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchObjects(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchObjectsMutationResult = NonNullable<
Awaited<ReturnType<typeof patchObjects>>
>;
export type PatchObjectsMutationBody =
| BodyType<CoretypesPatchableObjectsDTO>
| undefined;
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch objects for a role by relation
*/
export const usePatchObjects = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchObjects>>,
TError,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchObjects>>,
TError,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
> => {
return useMutation(getPatchObjectsMutationOptions(options));
};

View File

@@ -2230,13 +2230,6 @@ export interface AuthtypesOrgSessionContextDTO {
warning?: ErrorsJSONDTO;
}
export interface AuthtypesPatchableRoleDTO {
/**
* @type string
*/
description: string;
}
export interface AuthtypesPostableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
/**
@@ -3249,17 +3242,6 @@ export interface CommonJSONRefDTO {
$ref?: string;
}
export interface CoretypesPatchableObjectsDTO {
/**
* @type array,null
*/
additions: CoretypesObjectGroupDTO[] | null;
/**
* @type array,null
*/
deletions: CoretypesObjectGroupDTO[] | null;
}
export interface DashboardGridItemDTO {
content?: CommonJSONRefDTO;
/**
@@ -3995,6 +3977,14 @@ export interface DashboardtypesDashboardPanelRefDTO {
* @type string
*/
dashboardName: string;
/**
* @type array
*/
filterBy?: string[];
/**
* @type array
*/
groupBy?: string[];
/**
* @type string
*/
@@ -6919,6 +6909,11 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
* @type string
*/
id: string;
/**
* @type integer
* @minimum 0
*/
ingestedSamples: number;
/**
* @type integer
* @minimum 0
@@ -6934,10 +6929,10 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
*/
metricName: string;
/**
* @type number
* @format double
* @type integer
* @minimum 0
*/
reductionPercent: number;
retainedSamples: number;
/**
* @type integer
* @minimum 0
@@ -6996,11 +6991,21 @@ export interface MetricreductionruletypesGettableReductionRuleStatsDTO {
* @format double
*/
estimatedMonthlySavingsUsd: number;
/**
* @type integer
* @minimum 0
*/
ingestedSamples: number;
/**
* @type integer
* @minimum 0
*/
ingestedSeries: number;
/**
* @type integer
* @minimum 0
*/
retainedSamples: number;
/**
* @type integer
* @minimum 0
@@ -7056,7 +7061,6 @@ export enum MetricreductionruletypesReductionRuleOrderByDTO {
metric = 'metric',
ingested_volume = 'ingested_volume',
reduced_volume = 'reduced_volume',
reduction = 'reduction',
last_updated = 'last_updated',
}
export interface MetricreductionruletypesUpdatableReductionRuleDTO {
@@ -10248,31 +10252,9 @@ export type GetRole200 = {
status: string;
};
export type PatchRolePathParameters = {
id: string;
};
export type UpdateRolePathParameters = {
id: string;
};
export type GetObjectsPathParameters = {
id: string;
relation: string;
};
export type GetObjects200 = {
/**
* @type array
*/
data: CoretypesObjectGroupDTO[];
/**
* @type string
*/
status: string;
};
export type PatchObjectsPathParameters = {
id: string;
relation: string;
};
export type GetAllRoutePolicies200 = {
/**
* @type array

View File

@@ -2,8 +2,8 @@ import { Controller, useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { SACreatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';

View File

@@ -11,7 +11,7 @@ import {
import CreateServiceAccountModal from '../CreateServiceAccountModal';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,

View File

@@ -4,11 +4,11 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
buildSAAttachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate } from '../utils';

View File

@@ -1,8 +1,8 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { buildSADeletePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { buildSADeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';

View File

@@ -7,12 +7,12 @@ import { Input } from '@signozhq/ui/input';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
buildAPIKeyUpdatePermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate, formatLastObservedAt } from '../utils';

View File

@@ -16,8 +16,8 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import dayjs from 'dayjs';
import { buildAPIKeyUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { buildAPIKeyUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';

View File

@@ -4,13 +4,13 @@ import { Button } from '@signozhq/ui/button';
import { Skeleton, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
buildAPIKeyDeletePermission,
buildSAAttachPermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';

View File

@@ -5,11 +5,11 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { useCopyToClipboard } from 'react-use';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import RolesSelect from 'components/RolesSelect';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { buildSAUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { buildSAUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';

View File

@@ -1,11 +1,11 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';

View File

@@ -16,7 +16,7 @@ import {
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import { useRoles } from 'components/RolesSelect';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import {
@@ -35,8 +35,8 @@ import {
buildSADeletePermission,
buildSAReadPermission,
buildSAUpdatePermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
parseAsBoolean,
parseAsInteger,
@@ -47,7 +47,7 @@ import {
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
import KeysTab from './KeysTab';

View File

@@ -6,7 +6,7 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import EditKeyModal from '../EditKeyModal';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,

View File

@@ -6,7 +6,7 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import KeysTab from '../KeysTab';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,

View File

@@ -7,11 +7,11 @@ import {
setupAuthzAdmin,
setupAuthzDeny,
setupAuthzDenyAll,
} from 'tests/authz-test-utils';
} from 'lib/authz/utils/authz-test-utils';
import {
APIKeyListPermission,
buildSADeletePermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import ServiceAccountDrawer from '../ServiceAccountDrawer';

View File

@@ -3,7 +3,7 @@ import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { setupAuthzAdmin } from 'tests/authz-test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import ServiceAccountDrawer from '../ServiceAccountDrawer';

View File

@@ -22,6 +22,7 @@ import {
} from 'container/AIAssistant/store/useAIAssistantStore';
import { useThemeMode } from 'hooks/useDarkMode';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { IS_DEV } from 'lib/env';
import history from 'lib/history';
import { ROLES as UserRole } from 'types/roles';
@@ -30,6 +31,33 @@ import { useCmdK } from '../../providers/cmdKProvider';
import './cmdKPalette.scss';
const AuthZDevModal = IS_DEV
? React.lazy(() =>
import('lib/authz/devtools/AuthZDevModal/AuthZDevModal').then((m) => ({
default: m.AuthZDevModal,
})),
)
: null;
const AuthZDevFloatingIndicator = IS_DEV
? React.lazy(() =>
import('lib/authz/devtools/AuthZDevFloatingIndicator/AuthZDevFloatingIndicator').then(
(m) => ({
default: m.AuthZDevFloatingIndicator,
}),
),
)
: null;
const openAuthZDevModal = IS_DEV
? (): void => {
void import('lib/authz/devtools/useAuthZDevStore').then((m) => {
m.openAuthZDevModal();
return m;
});
}
: undefined;
type CmdAction = {
id: string;
name: string;
@@ -110,6 +138,7 @@ export function CmdKPalette({
aiAssistant: isAIAssistantEnabled
? { open: handleOpenAIAssistant }
: undefined,
authzDevTools: openAuthZDevModal ? { open: openAuthZDevModal } : undefined,
});
// RBAC filter: show action if no roles set OR current user role is included
@@ -146,37 +175,57 @@ export function CmdKPalette({
};
return (
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
<CommandList className="cmdk-list-scroll">
<CommandEmpty>No results</CommandEmpty>
{grouped.map(([section, items]) => (
<CommandGroup
key={section}
heading={section}
className="cmdk-section-heading"
>
{items.map((it) => (
<CommandItem
key={it.id}
onSelect={(): void => handleInvoke(it)}
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
<span
className={cx('cmd-item-icon', it.id === 'ai-assistant' && 'noz-icon')}
<>
<CommandDialog
open={open}
onOpenChange={setOpen}
position="top"
offset={110}
>
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
<CommandList className="cmdk-list-scroll">
<CommandEmpty>No results</CommandEmpty>
{grouped.map(([section, items]) => (
<CommandGroup
key={section}
heading={section}
className="cmdk-section-heading"
>
{items.map((it) => (
<CommandItem
key={it.id}
onSelect={(): void => handleInvoke(it)}
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
{it.icon}
</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
<span
className={cx(
'cmd-item-icon',
it.id === 'ai-assistant' && 'noz-icon',
)}
>
{it.icon}
</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
{IS_DEV && AuthZDevModal && (
<React.Suspense fallback={null}>
<AuthZDevModal />
</React.Suspense>
)}
{IS_DEV && AuthZDevFloatingIndicator && (
<React.Suspense fallback={null}>
<AuthZDevFloatingIndicator />
</React.Suspense>
)}
</>
);
}

View File

@@ -43,10 +43,17 @@ type ActionDeps = {
aiAssistant?: {
open: () => void;
};
/**
* Provided only in development mode. Opens the AuthZ DevTools modal
* for testing permission overrides.
*/
authzDevTools?: {
open: () => void;
};
};
export function createShortcutActions(deps: ActionDeps): CmdAction[] {
const { navigate, handleThemeChange, aiAssistant } = deps;
const { navigate, handleThemeChange, aiAssistant, authzDevTools } = deps;
const actions: CmdAction[] = [
{
@@ -302,5 +309,17 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
});
}
if (authzDevTools) {
actions.push({
id: 'authz-devtools',
name: 'AuthZ DevTools',
keywords: 'authz permissions rbac debug devtools override testing',
section: 'Dev',
icon: <Settings size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: authzDevTools.open,
});
}
return actions;
}

View File

@@ -1,4 +1,5 @@
import { Gauge } from '@signozhq/icons';
import { Tooltip } from 'antd';
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { Badge } from '@signozhq/ui/badge';
@@ -8,16 +9,26 @@ interface VolumeControlBadgeProps {
}
function VolumeControlBadge({ rule }: VolumeControlBadgeProps): JSX.Element {
return (
const badge = (
<Badge
data-testid="vc-badge-active"
variant="outline"
color={!rule.active ? 'success' : 'warning'}
color={rule.active ? 'success' : 'warning'}
>
<Gauge size={12} />
{!rule.active ? 'Active' : 'Pending'}
{rule.active ? 'Active' : 'Pending'}
</Badge>
);
if (rule.active) {
return badge;
}
return (
<Tooltip title="Takes about 5 minutes to take effect">
<span>{badge}</span>
</Tooltip>
);
}
export default VolumeControlBadge;

View File

@@ -7,6 +7,12 @@
padding: 12px 16px 0 16px;
}
.chartHeader {
display: flex;
flex-direction: column;
gap: 2px;
}
.chartTitle {
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -15,3 +21,11 @@
.chartBody {
height: 340px;
}
.chartStatus {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}

View File

@@ -1,5 +1,6 @@
import { Color } from '@signozhq/design-tokens';
import { Typography } from '@signozhq/ui/typography';
import { Spin } from 'antd';
import { useGetMetricReductionRuleTimeseries } from 'api/generated/services/metrics';
import { PANEL_TYPES } from 'constants/queryBuilder';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
@@ -25,7 +26,9 @@ interface VolumeControlChartProps {
}
function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
const { data } = useGetMetricReductionRuleTimeseries({ query: { enabled } });
const { data, isLoading, isError } = useGetMetricReductionRuleTimeseries({
query: { enabled },
});
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
@@ -65,11 +68,34 @@ function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
return (
<div className={styles.chart} data-testid="volume-control-chart">
<Typography.Text className={styles.chartTitle} size={'small'}>
Series volume over time · ingested vs retained
</Typography.Text>
<div className={styles.chartHeader}>
<Typography.Text className={styles.chartTitle} size={'small'}>
Sample volume · ingested vs retained
</Typography.Text>
<Typography.Text size="small" color="muted">
Last 6 hours
</Typography.Text>
</div>
<div className={styles.chartBody} ref={graphRef}>
{dimensions.width > 0 && (
{isLoading && (
<div
className={styles.chartStatus}
data-testid="volume-control-chart-loading"
>
<Spin size="small" />
</div>
)}
{!isLoading && isError && (
<div
className={styles.chartStatus}
data-testid="volume-control-chart-error"
>
<Typography.Text size="small" color="danger">
Failed to load chart
</Typography.Text>
</div>
)}
{!isLoading && !isError && dimensions.width > 0 && (
<BarChart
config={config}
data={chartData}

View File

@@ -17,11 +17,27 @@
gap: 5px;
}
.meterLabelRow {
display: flex;
align-items: center;
gap: 4px;
}
.meterLabel {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.meterInfo {
flex-shrink: 0;
color: var(--bg-vanilla-400, #c0c1c3);
line-height: 1;
cursor: help;
}
.meterValue {
display: flex;
align-items: baseline;
gap: 6px;
font-family: 'Geist Mono', monospace;
}

View File

@@ -1,6 +1,8 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { Spin } from 'antd';
import { Spin, Tooltip } from 'antd';
import { MetricreductionruletypesGettableReductionRulePreviewDTO } from 'api/generated/services/sigNoz.schemas';
import { popupContainer } from 'utils/selectPopupContainer';
import { formatCompact } from '../../../configUtils';
import { RuleMode } from '../../../types';
@@ -27,6 +29,7 @@ function ImpactPanel({
);
}
const full = preview?.ingestedSeries ?? 0;
const current = preview?.currentRetainedSeries ?? 0;
const proposed = preview?.retainedSeries ?? 0;
const deltaPct = current > 0 ? (1 - proposed / current) * 100 : 0;
@@ -40,31 +43,59 @@ function ImpactPanel({
{!isLoading && preview && (
<div className={styles.meterGrid}>
<div className={styles.meter}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Current series
<div className={styles.meterLabelRow}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Full series
</Typography.Text>
<Tooltip
title="Total number of series for this metric before any reduction."
getPopupContainer={popupContainer}
>
<Info size={12} className={styles.meterInfo} />
</Tooltip>
</div>
<Typography.Text size="2xl" className={styles.meterValue}>
{formatCompact(full)}
</Typography.Text>
</div>
<div className={styles.meter}>
<div className={styles.meterLabelRow}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Current retained
</Typography.Text>
<Tooltip
title="Series kept today under the metric's existing rule, or all of them if it has no rule yet."
getPopupContainer={popupContainer}
>
<Info size={12} className={styles.meterInfo} />
</Tooltip>
</div>
<Typography.Text size="2xl" className={styles.meterValue}>
{formatCompact(current)}
</Typography.Text>
</div>
<div className={styles.meter}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Proposed series
</Typography.Text>
<div className={styles.meterLabelRow}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Potential retained
</Typography.Text>
<Tooltip
title="Series that would be kept if you save this rule, with the reduction vs what's retained today."
getPopupContainer={popupContainer}
>
<Info size={12} className={styles.meterInfo} />
</Tooltip>
</div>
<Typography.Text size="2xl" className={styles.meterValue}>
{formatCompact(proposed)}
</Typography.Text>
</div>
<div className={styles.meter}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Reduction
</Typography.Text>
<Typography.Text
size="2xl"
color={deltaPct >= 0 ? 'success' : undefined}
className={styles.meterValue}
>
{reductionLabel}
{deltaPct !== 0 && (
<Typography.Text
size="small"
color={deltaPct >= 0 ? 'success' : undefined}
>
{reductionLabel}
</Typography.Text>
)}
</Typography.Text>
</div>
</div>

View File

@@ -18,12 +18,12 @@ const MODE_OPTIONS: ModeOption[] = [
},
{
mode: 'include',
title: 'Include attributes',
title: 'Include',
description: 'Allowlist: only the selected attributes stay queryable.',
},
{
mode: 'exclude',
title: 'Exclude attributes',
title: 'Exclude',
description: 'Blocklist: the selected attributes are aggregated away.',
},
];

View File

@@ -53,12 +53,12 @@ function RelatedAssetsWarning({
<div className={styles.warning} data-testid="volume-control-warning">
<Info size={14} />
<div className={styles.warningBody}>
<Typography.Text as="div" size="small" weight="semibold" color="warning">
<Typography.Text as="div" size="base" weight="semibold" color="warning">
This rule affects {impacted.length} related asset
{impacted.length > 1 ? 's' : ''}.
</Typography.Text>
{impactedLabels.length > 0 && (
<Typography.Text as="div" size="sm" color="muted">
<Typography.Text as="div" size="base" color="muted">
{impactedLabels.join(', ')} will no longer be queryable; affected panels
fall back to aggregated data once the rule applies.
</Typography.Text>
@@ -73,7 +73,7 @@ function RelatedAssetsWarning({
<li key={`${asset.type}-${asset.id}-${asset.widget?.id ?? ''}`}>
{href ? (
<Typography.Link
size="sm"
size="base"
href={href}
target="_blank"
rel="noopener noreferrer"
@@ -81,7 +81,7 @@ function RelatedAssetsWarning({
{label}
</Typography.Link>
) : (
<Typography.Text size="sm" color="muted">
<Typography.Text size="base" color="muted">
{label}
</Typography.Text>
)}

View File

@@ -42,6 +42,10 @@ function VolumeControlConfigDrawer({
const footer = (
<div className={styles.footer}>
<Typography.Text size="small" color="muted">
Changes take effect about 5 minutes after saving.
</Typography.Text>
<div className={styles.footerSpacer} />
<Button
variant="outlined"
color="secondary"
@@ -50,7 +54,6 @@ function VolumeControlConfigDrawer({
>
Cancel
</Button>
<div className={styles.footerSpacer} />
{hasExistingRule && (
<Button
variant="ghost"

View File

@@ -8,8 +8,8 @@ function PendingActivationBanner(): JSX.Element {
<div className={styles.banner} data-testid="volume-control-pending-banner">
<Info size={13} />
<Typography.Text size="sm" color="muted">
This metric&apos;s configuration was recently updated. Volume changes will
take effect within a few minutes.
This metric&apos;s configuration was recently updated. Volume changes take
effect within about 5 minutes.
</Typography.Text>
</div>
);

View File

@@ -22,7 +22,7 @@ function VolumeControlSection({
useVolumeControlFeatureGate();
const [isConfigOpen, setIsConfigOpen] = useState(false);
const { data, isLoading, error } = useListMetricReductionRules(
const { data, isLoading, isError } = useListMetricReductionRules(
{ metricName },
{
query: {
@@ -37,7 +37,7 @@ function VolumeControlSection({
}
const rule = data?.data.rules?.[0];
const hasRule = !!rule && !error;
const hasRule = !!rule && !isError;
const openConfig = (): void => setIsConfigOpen(true);
const closeConfig = (): void => setIsConfigOpen(false);
@@ -53,6 +53,16 @@ function VolumeControlSection({
{isLoading && <Skeleton active title={false} paragraph={{ rows: 2 }} />}
{!isLoading && isError && (
<Typography.Text
size="small"
color="danger"
data-testid="volume-control-section-error"
>
Failed to load volume control. Please try again.
</Typography.Text>
)}
{!isLoading && hasRule && rule && !rule.active && (
<PendingActivationBanner />
)}
@@ -65,7 +75,7 @@ function VolumeControlSection({
/>
)}
{!isLoading && !hasRule && (
{!isLoading && !isError && !hasRule && (
<NoRuleEmptyState canManage={canManageVolumeControl} onSetup={openConfig} />
)}

View File

@@ -1,3 +1,9 @@
.statsSection {
display: flex;
flex-direction: column;
gap: 8px;
}
.stats {
display: flex;
flex-wrap: wrap;
@@ -21,11 +27,24 @@
background: var(--callout-success-background);
}
.statCardLabelRow {
display: flex;
align-items: center;
gap: 4px;
}
.statCardLabel {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.statCardInfo {
flex-shrink: 0;
color: var(--bg-vanilla-400, #c0c1c3);
line-height: 1;
cursor: help;
}
.statCardValue {
display: flex;
align-items: baseline;

View File

@@ -1,4 +1,6 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton, Tooltip } from 'antd';
import cx from 'classnames';
import { formatCompact, formatUsd } from '../../../configUtils';
@@ -8,12 +10,17 @@ interface VolumeControlStatsProps {
activeRules: number;
ingestedSeries: number;
retainedSeries: number;
ingestedSamples: number;
retainedSamples: number;
estimatedMonthlySavingsUsd: number;
isLoading?: boolean;
isError?: boolean;
}
interface StatItem {
label: string;
value: string;
tooltip: string;
delta?: string;
unit?: string;
highlighted?: boolean;
@@ -24,20 +31,53 @@ function VolumeControlStats({
activeRules,
ingestedSeries,
retainedSeries,
ingestedSamples,
retainedSamples,
estimatedMonthlySavingsUsd,
isLoading = false,
isError = false,
}: VolumeControlStatsProps): JSX.Element {
const overallReduction =
ingestedSeries > 0
? Math.round((1 - retainedSeries / ingestedSeries) * 100)
: 0;
const sampleReduction =
ingestedSamples > 0
? Math.round((1 - retainedSamples / ingestedSamples) * 100)
: 0;
const items: StatItem[] = [
{ label: 'Active rules', value: String(activeRules) },
{ label: 'Ingested series', value: formatCompact(ingestedSeries) },
{
label: 'Configured rules',
value: String(activeRules),
tooltip: 'Volume-control rules currently configured for this workspace.',
},
{
label: 'Ingested series',
value: formatCompact(ingestedSeries),
tooltip:
'Distinct time series across all metrics in the last 1 hour, before any reduction.',
},
{
label: 'Retained series',
value: formatCompact(retainedSeries),
delta: overallReduction > 0 ? `${overallReduction}%` : undefined,
tooltip:
'Distinct time series kept across all metrics in the last 1 hour; everything except what the rules reduce away. Lower than ingested means more reduction.',
},
{
label: 'Ingested samples',
value: formatCompact(ingestedSamples),
tooltip:
'Sample data points across all metrics in the last 1 hour, before any reduction.',
},
{
label: 'Retained samples',
value: formatCompact(retainedSamples),
delta: sampleReduction > 0 ? `${sampleReduction}%` : undefined,
tooltip:
'Sample data points kept across all metrics in the last 1 hour; everything except what the rules reduce. Samples reduce more than series because series do not all carry the same sample volume.',
},
{
label: 'Est. monthly savings',
@@ -45,42 +85,73 @@ function VolumeControlStats({
unit: '/mo',
highlighted: true,
valueGood: true,
tooltip:
'Rough monthly estimate: the samples the rules reduced in the last 1 hour, scaled to a month at 1-month standard retention. It is extrapolated from a single rolling hour.',
},
];
return (
<div className={styles.stats} data-testid="volume-control-stats">
{items.map((item) => (
<div
key={item.label}
className={cx(styles.statCard, {
[styles.statCardHighlighted]: item.highlighted,
})}
>
<Typography.Text size="sm" color="muted" className={styles.statCardLabel}>
{item.label}
</Typography.Text>
<Typography.Text
as="div"
size="large"
weight="semibold"
color={item.valueGood ? 'success' : undefined}
className={styles.statCardValue}
<div className={styles.statsSection}>
<Typography.Text size="small" color="muted">
Last 1 hour
</Typography.Text>
<div className={styles.stats} data-testid="volume-control-stats">
{items.map((item) => (
<div
key={item.label}
className={cx(styles.statCard, {
[styles.statCardHighlighted]: item.highlighted,
})}
>
{item.value}
{item.delta && (
<Typography.Text size="small" weight="semibold" color="success">
{item.delta}
<div className={styles.statCardLabelRow}>
<Typography.Text
size="sm"
color="muted"
className={styles.statCardLabel}
>
{item.label}
</Typography.Text>
<Tooltip title={item.tooltip}>
<Info size={12} className={styles.statCardInfo} />
</Tooltip>
</div>
{isLoading && (
<Skeleton.Button active size="small" className={styles.statCardValue} />
)}
{!isLoading && isError && (
<Typography.Text
as="div"
size="small"
color="danger"
className={styles.statCardValue}
>
Failed to load
</Typography.Text>
)}
{item.unit && (
<Typography.Text size="small" weight="medium" color="muted">
{item.unit}
{!isLoading && !isError && (
<Typography.Text
as="div"
size="large"
weight="semibold"
color={item.valueGood ? 'success' : undefined}
className={styles.statCardValue}
>
{item.value}
{item.delta && (
<Typography.Text size="small" weight="semibold" color="success">
{item.delta}
</Typography.Text>
)}
{item.unit && (
<Typography.Text size="small" weight="medium" color="muted">
{item.unit}
</Typography.Text>
)}
</Typography.Text>
)}
</Typography.Text>
</div>
))}
</div>
))}
</div>
</div>
);
}

View File

@@ -13,8 +13,10 @@
font-family: 'Geist Mono', monospace;
}
.reductionCell {
font-family: 'Geist Mono', monospace;
.volumeCell {
display: flex;
flex-direction: column;
gap: 2px;
}
.empty {

View File

@@ -36,7 +36,7 @@ type VolumeControlTableParams = Required<
>;
const DEFAULT_PARAMS: VolumeControlTableParams = {
orderBy: OrderBy.reduction,
orderBy: OrderBy.ingested_volume,
order: SortOrder.desc,
search: '',
offset: 0,
@@ -60,11 +60,20 @@ function VolumeControlTab(): JSX.Element {
);
}, [debouncedSearch]);
const { data, isLoading } = useListMetricReductionRules(params, {
const {
data,
isLoading,
isError: isListError,
} = useListMetricReductionRules(params, {
query: { enabled: isVolumeControlEnabled },
});
const { data: statsData } = useGetMetricReductionRuleStats({
const {
data: statsData,
isLoading: isStatsLoading,
isFetching: isStatsFetching,
isError: isStatsError,
} = useGetMetricReductionRuleStats({
query: { enabled: isVolumeControlEnabled },
});
const stats = statsData?.data;
@@ -111,7 +120,7 @@ function VolumeControlTab(): JSX.Element {
{
title: 'MODE',
key: 'mode',
width: 160,
width: 110,
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
@@ -138,7 +147,14 @@ function VolumeControlTab(): JSX.Element {
),
},
{
title: 'INGESTED',
title: (
<>
INGESTED{' '}
<Typography.Text size="small" color="muted">
(1h)
</Typography.Text>
</>
),
key: OrderBy.ingested_volume,
width: 130,
sorter: true,
@@ -147,13 +163,28 @@ function VolumeControlTab(): JSX.Element {
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<Typography.Text size="small" color="muted">
{formatCompact(rule.ingestedSeries)}
</Typography.Text>
<div className={styles.volumeCell}>
<Typography.Text size="small">
{formatCompact(rule.ingestedSeries)}{' '}
<Typography.Text size="small" color="muted">
series
</Typography.Text>
</Typography.Text>
<Typography.Text size="small" color="muted">
{formatCompact(rule.ingestedSamples)} samples
</Typography.Text>
</div>
),
},
{
title: 'RETAINED',
title: (
<>
RETAINED{' '}
<Typography.Text size="small" color="muted">
(1h)
</Typography.Text>
</>
),
key: OrderBy.reduced_volume,
width: 130,
sorter: true,
@@ -162,22 +193,35 @@ function VolumeControlTab(): JSX.Element {
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<Typography.Text size="small">
{formatCompact(rule.retainedSeries)}
</Typography.Text>
<div className={styles.volumeCell}>
<Typography.Text size="small">
{formatCompact(rule.retainedSeries)}{' '}
<Typography.Text size="small" color="muted">
series
</Typography.Text>
</Typography.Text>
<Typography.Text size="small" color="muted">
{formatCompact(rule.retainedSamples)} samples
</Typography.Text>
</div>
),
},
{
title: 'CHANGE',
key: OrderBy.reduction,
width: 110,
sorter: true,
sortOrder: sortOrderFor(OrderBy.reduction),
width: 140,
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => {
if (rule.reductionPercent <= 0) {
const seriesReduction =
rule.ingestedSeries > 0
? (1 - rule.retainedSeries / rule.ingestedSeries) * 100
: 0;
const samplesReduction =
rule.ingestedSamples > 0
? (1 - rule.retainedSamples / rule.ingestedSamples) * 100
: 0;
if (seriesReduction <= 0 && samplesReduction <= 0) {
return (
<Typography.Text size="small" color="muted">
@@ -185,14 +229,18 @@ function VolumeControlTab(): JSX.Element {
);
}
return (
<Typography.Text
size="small"
weight="semibold"
color="success"
className={styles.reductionCell}
>
{Math.round(rule.reductionPercent)}%
</Typography.Text>
<div className={styles.volumeCell}>
<Typography.Text size="small" weight="semibold" color="success">
{seriesReduction > 0 ? `${Math.round(seriesReduction)}%` : '0%'}{' '}
<Typography.Text size="small" color="muted">
series
</Typography.Text>
</Typography.Text>
<Typography.Text size="small" color="muted">
{samplesReduction > 0 ? `${Math.round(samplesReduction)}%` : '0%'}{' '}
samples
</Typography.Text>
</div>
);
},
},
@@ -273,7 +321,11 @@ function VolumeControlTab(): JSX.Element {
activeRules={total}
ingestedSeries={stats?.ingestedSeries ?? 0}
retainedSeries={stats?.retainedSeries ?? 0}
ingestedSamples={stats?.ingestedSamples ?? 0}
retainedSamples={stats?.retainedSamples ?? 0}
estimatedMonthlySavingsUsd={stats?.estimatedMonthlySavingsUsd ?? 0}
isLoading={isStatsLoading || isStatsFetching}
isError={isStatsError}
/>
<VolumeControlChart enabled={isVolumeControlEnabled} />
@@ -293,7 +345,13 @@ function VolumeControlTab(): JSX.Element {
showSizeChanger: false,
}}
locale={{
emptyText: (
emptyText: isListError ? (
<div className={styles.empty} data-testid="volume-control-tab-error">
<Typography.Text color="danger">
Failed to load volume control rules. Please try again.
</Typography.Text>
</div>
) : (
<div className={styles.empty} data-testid="volume-control-tab-empty">
<Typography.Text color="muted">
No volume control rules yet. Open a metric and set one up to start

View File

@@ -49,6 +49,7 @@ export interface UseVolumeControlConfigResult {
const PREVIEW_DEBOUNCE_MS = 400;
const SAVE_ERROR_MESSAGE = 'Failed to save volume control rule';
const REMOVE_ERROR_MESSAGE = 'Failed to remove volume control rule';
const PREVIEW_ERROR_MESSAGE = 'Failed to preview volume control rule';
export function useVolumeControlConfig({
metricName,
@@ -95,11 +96,25 @@ export function useVolumeControlConfig({
const timer = setTimeout(() => {
previewMutate(
{ data: { metricName, matchType: matchTypeForMode(mode), labels } },
{ onSettled: () => setIsPreviewPending(false) },
{
onError: (error) =>
notifications.error({
message: error.response?.data?.error?.message ?? PREVIEW_ERROR_MESSAGE,
}),
onSettled: () => setIsPreviewPending(false),
},
);
}, PREVIEW_DEBOUNCE_MS);
return (): void => clearTimeout(timer);
}, [open, mode, labels, metricName, previewMutate, previewReset]);
}, [
open,
mode,
labels,
metricName,
previewMutate,
previewReset,
notifications,
]);
const createMutation = useCreateMetricReductionRule();
const updateMutation = useUpdateMetricReductionRuleByID();
@@ -142,7 +157,10 @@ export function useVolumeControlConfig({
}
const onSuccess = (): void => {
notifications.success({ message: 'Volume control rule saved' });
notifications.success({
message:
'Volume control rule saved. It takes about 5 minutes to take effect.',
});
invalidate();
onClose();
};

View File

@@ -9,7 +9,7 @@ export function isKeepMode(
export function getMatchTypeLabel(
matchType: MetricreductionruletypesMatchTypeDTO,
): string {
return isKeepMode(matchType) ? 'Include attributes' : 'Exclude attributes';
return isKeepMode(matchType) ? 'Include' : 'Exclude';
}
export function getLabelVerb(

View File

@@ -7,7 +7,7 @@ import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import ROUTES from 'constants/routes';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import useUrlQuery from 'hooks/useUrlQuery';

View File

@@ -1,13 +1,16 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { FeatureKeys } from 'constants/features';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import {
invalidLicense,
mockUseAuthZGrantAll,
} from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
beforeEach(() => {

View File

@@ -1,12 +1,12 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { render, screen } from 'tests/test-utils';
import { mockUseAuthZDenyAll } from 'tests/authz-test-utils';
import { mockUseAuthZDenyAll } from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
afterEach(() => {
@@ -48,6 +48,8 @@ describe('CreateRolePage - AuthZ', () => {
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});

View File

@@ -3,12 +3,12 @@ import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const rolesApiBase = '*/api/v1/roles';

View File

@@ -1,15 +1,15 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { render, screen } from 'tests/test-utils';
import {
mockUseAuthZDenyAll,
mockUseAuthZGrantByPrefix,
} from 'tests/authz-test-utils';
} from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const EDIT_ROLE_ID = 'test-role-123';
@@ -77,6 +77,8 @@ describe('EditRolePage - AuthZ', () => {
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});

View File

@@ -4,12 +4,12 @@ import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';

View File

@@ -1,13 +1,13 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { render, screen, userEvent, within } from 'tests/test-utils';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
import { TooltipProvider } from '@signozhq/ui/tooltip';
jest.mock('hooks/useAuthZ/useAuthZ');
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
beforeEach(() => {

View File

@@ -1,13 +1,13 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('hooks/useAuthZ/useAuthZ');
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
async function expandAllCards(): Promise<void> {

View File

@@ -9,7 +9,7 @@ import { getResourcePanel } from '../../permissions.config';
import ItemInputSelector from './ItemInputSelector';
import styles from './ActionToggle.module.scss';
import { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
import { getActionLabel } from 'container/RolesSettings/ViewRolePage/components/permissionDisplay.utils';
const SCOPE_LABELS: Record<PermissionScope, string> = {

View File

@@ -5,7 +5,7 @@ import { ConfirmDialog } from '@signozhq/ui/dialog';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import type { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
import { PermissionScope, ResourcePermissions } from '../../types';
import type { EditorMode, JsonEditorRef } from './JsonEditor.types';

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import type { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
import { Typography } from '@signozhq/ui/typography';

View File

@@ -1,5 +1,5 @@
import type { Monaco } from '@monaco-editor/react';
import permissionsType from 'hooks/useAuthZ/permissions.type';
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
import transactionGroupSchema from 'schemas/generated/transactionGroups.schema.json';
export const TRANSACTION_GROUP_SCHEMA = transactionGroupSchema;

View File

@@ -5,10 +5,10 @@ import { Pagination, Skeleton } from 'antd';
import { useListRoles } from 'api/generated/services/role';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import ROUTES from 'constants/routes';
import { RoleListPermission } from 'hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import useUrlQuery from 'hooks/useUrlQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';

View File

@@ -3,9 +3,9 @@ import { useHistory } from 'react-router-dom';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import ROUTES from 'constants/routes';
import { RoleCreatePermission } from 'hooks/useAuthZ/permissions/role.permissions';
import { RoleCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import RolesListingTable from './RolesComponents/RolesListingTable';

View File

@@ -10,7 +10,7 @@ import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import { useGetRole } from 'api/generated/services/role';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import { useDeleteRoleModal } from 'container/RolesSettings/DeleteRoleModal/useDeleteRoleModal';
import { useRoleAuthZ } from 'container/RolesSettings/hooks/useRoleAuthZ';
import { transformApiToRolePermissions } from 'container/RolesSettings/hooks/useRolePermissions';

View File

@@ -1,7 +1,7 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import userEvent from '@testing-library/user-event';
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
customRoleResponse,
managedRoleResponse,
@@ -10,7 +10,7 @@ import {
mockUseAuthZDenyAll,
mockUseAuthZGrantAll,
mockUseAuthZGrantByPrefix,
} from 'tests/authz-test-utils';
} from 'lib/authz/utils/authz-test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
@@ -409,6 +409,8 @@ describe('ViewRolePage - AuthZ', () => {
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});

View File

@@ -1,7 +1,7 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { render, screen } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';

View File

@@ -1,9 +1,9 @@
import { Route, Switch } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { render, screen } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';

View File

@@ -1,8 +1,11 @@
import * as roleApi from 'api/generated/services/role';
import { FeatureKeys } from 'constants/features';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import {
invalidLicense,
mockUseAuthZGrantAll,
} from 'lib/authz/utils/authz-test-utils';
import ViewRolePage from '../ViewRolePage';

View File

@@ -1,6 +1,6 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { render } from 'tests/test-utils';
import ViewRolePage from '../ViewRolePage';

View File

@@ -1,7 +1,7 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import userEvent from '@testing-library/user-event';
import { render, screen, within } from 'tests/test-utils';

View File

@@ -3,12 +3,12 @@ import {
CoretypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
customRoleResponse,
managedRoleResponse,
} from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';

View File

@@ -11,12 +11,15 @@ import {
userEvent,
} from 'tests/test-utils';
import { FeatureKeys } from 'constants/features';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
invalidLicense,
mockUseAuthZGrantAll,
} from 'lib/authz/utils/authz-test-utils';
import RolesSettings from '../RolesSettings';
jest.mock('hooks/useAuthZ/useAuthZ');
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const rolesApiURL = 'http://localhost/api/v1/roles';

View File

@@ -4,7 +4,7 @@ import {
CoretypesKindDTO,
CoretypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import type { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
import {
ActionConfig,

View File

@@ -4,9 +4,12 @@ import {
buildRoleReadPermission,
buildRoleUpdatePermission,
RoleCreatePermission,
} from 'hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { ParsedPermissionObject, parsePermission } from 'hooks/useAuthZ/utils';
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
ParsedPermissionObject,
parsePermission,
} from 'lib/authz/hooks/useAuthZ/utils';
interface UseRoleAuthZResult {
readRolePermission: ParsedPermissionObject;

View File

@@ -18,7 +18,7 @@ import {
useGetRole,
useUpdateRole,
} from 'api/generated/services/role';
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import type { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
import {
getResourcePanel,

View File

@@ -1,12 +1,12 @@
import { Bot, Key, Shield } from '@signozhq/icons';
import permissionsType from 'hooks/useAuthZ/permissions.type';
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
import {
AuthZResource,
AuthZVerb,
OBJECT_SCOPED_VERBS,
ObjectScopedVerb,
} from 'hooks/useAuthZ/types';
} from 'lib/authz/hooks/useAuthZ/types';
import { CoretypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
/** Shared shape of the icon components exported by `@signozhq/icons`. */
@@ -84,7 +84,7 @@ export function getResourceVerbs(
}
// Role resource cannot have assignee verb
// TODO(H4ad): Remove this once we get rid of frontend/src/hooks/useAuthZ/legacy.ts
// TODO(H4ad): Remove this once we get rid of frontend/lib/authz/hooks/useAuthZ/legacy.ts
if (resource === 'role') {
return match.allowedVerbs.filter((verb) => verb !== 'assignee');
}

View File

@@ -1,4 +1,4 @@
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
import type { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
import { CoretypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
export enum PermissionScope {

View File

@@ -3,7 +3,10 @@ import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, waitFor } from 'tests/test-utils';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
} from 'lib/authz/utils/authz-test-utils';
import ServiceAccountsSettings from './ServiceAccountsSettings';
const SA_LIST_URL = 'http://localhost/api/v1/service_accounts';

View File

@@ -4,10 +4,10 @@ import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Input } from '@signozhq/ui/input';
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import Spinner from 'components/Spinner';
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
import ServiceAccountsTable, {
@@ -16,8 +16,8 @@ import ServiceAccountsTable, {
import {
SACreatePermission,
SAListPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
parseAsBoolean,
parseAsInteger,

View File

@@ -4,7 +4,7 @@ import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { setupAuthzAdmin } from 'tests/authz-test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import ServiceAccountsSettings from '../ServiceAccountsSettings';

View File

@@ -24,6 +24,11 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { withBasePath } from 'utils/basePath';
import { getGraphType } from 'utils/getGraphType';
/**
* @deprecated V1-only. V2 dashboards seed alerts from a panel via
* `useCreateAlertFromPanel` / `buildCreateAlertUrl`
* (pages/DashboardPageV2/.../Panel). Do not use in new code.
*/
const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const queryRangeMutation = useMutation(getSubstituteVars);

View File

@@ -1,11 +1,14 @@
import { ReactElement } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { buildPermission } from 'hooks/useAuthZ/utils';
import type { AuthZObject, BrandedPermission } from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
import type {
AuthZObject,
BrandedPermission,
} from 'lib/authz/hooks/useAuthZ/types';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import AuthZTooltip from './AuthZTooltip';
jest.mock('hooks/useAuthZ/useAuthZ');
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const noPermissions = {
@@ -13,6 +16,8 @@ const noPermissions = {
isFetching: false,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
};

View File

@@ -5,9 +5,9 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import type { BrandedPermission } from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { formatPermission } from 'hooks/useAuthZ/utils';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
import { useAppContext } from 'providers/App/App';
import styles from './AuthZTooltip.module.scss';

View File

@@ -1,10 +1,13 @@
import { ReactElement } from 'react';
import { BrandedPermission } from 'hooks/useAuthZ/types';
import { buildPermission } from 'hooks/useAuthZ/utils';
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
} from 'lib/authz/utils/authz-test-utils';
import { GuardAuthZ } from './GuardAuthZ';

View File

@@ -3,9 +3,9 @@ import {
AuthZObject,
AuthZRelation,
BrandedPermission,
} from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { buildPermission } from 'hooks/useAuthZ/utils';
} from 'lib/authz/hooks/useAuthZ/types';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
export type GuardAuthZProps<R extends AuthZRelation> = {
children: ReactElement;

View File

@@ -7,7 +7,10 @@ import type {
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
} from 'lib/authz/utils/authz-test-utils';
import { createGuardedRoute } from './createGuardedRoute';

View File

@@ -4,13 +4,13 @@ import {
AuthZObject,
AuthZRelation,
BrandedPermission,
} from 'hooks/useAuthZ/types';
import { formatPermission } from 'hooks/useAuthZ/utils';
} from 'lib/authz/hooks/useAuthZ/types';
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
import { useAppContext } from 'providers/App/App';
import noDataUrl from '@/assets/Icons/no-data.svg';
import noDataUrl from 'assets/Icons/no-data.svg';
import AppLoading from '../AppLoading/AppLoading';
import AppLoading from '../../../../components/AppLoading/AppLoading';
import { GuardAuthZ } from '../GuardAuthZ/GuardAuthZ';
import './createGuardedRoute.styles.scss';

View File

@@ -0,0 +1,38 @@
.container {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 9998;
pointer-events: auto;
display: flex;
align-items: center;
gap: 4px;
}
.button {
display: flex;
align-items: center;
gap: 8px;
box-shadow:
0 4px 12px rgb(0 0 0 / 15%),
0 0 0 1px rgb(0 0 0 / 5%);
}
.badge {
margin-left: 4px;
}
.closeButton {
padding: 4px;
min-width: auto;
background: var(--bg-vanilla-300);
border-radius: 4px;
box-shadow:
0 4px 12px rgb(0 0 0 / 15%),
0 0 0 1px rgb(0 0 0 / 5%);
}
.closeButton:hover {
background: var(--bg-vanilla-400);
}

View File

@@ -0,0 +1,62 @@
import { X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { useAuthZDevStore } from '../useAuthZDevStore';
import styles from './AuthZDevFloatingIndicator.module.css';
export function AuthZDevFloatingIndicator(): JSX.Element | null {
const overrides = useAuthZDevStore((s) => s.overrides);
const isModalOpen = useAuthZDevStore((s) => s.isModalOpen);
const openModal = useAuthZDevStore((s) => s.openModal);
const [isDismissed, setIsDismissed] = useState(false);
const overrideCount = Object.keys(overrides).length;
if (overrideCount === 0 || isModalOpen || isDismissed) {
return null;
}
const handleOpen = (): void => {
setIsDismissed(false);
openModal();
};
const handleDismiss = (e: React.MouseEvent): void => {
e.stopPropagation();
setIsDismissed(true);
};
return createPortal(
<div className={styles.container}>
<Button
variant="solid"
color="warning"
size="sm"
onClick={handleOpen}
className={styles.button}
data-testid="authz-dev-floating-indicator"
>
AuthZ Overrides
<Badge color="warning" className={styles.badge}>
{overrideCount}
</Badge>
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={handleDismiss}
className={styles.closeButton}
aria-label="Dismiss indicator"
data-testid="authz-dev-floating-dismiss"
>
<X size={14} />
</Button>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,140 @@
.modal {
--dialog-width: 640px;
--dialog-max-width: 92vw;
--dialog-max-height: 78vh;
--dialog-description-padding: var(--spacing-4) var(--spacing-4) 0px
var(--spacing-4);
[data-slot='dialog-description'],
[data-slot='dialog-header'] {
background-color: var(--l2-background);
}
}
.content {
display: flex;
flex-direction: column;
max-height: calc(78vh - 80px);
}
.header {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: var(--spacing-4);
padding-bottom: var(--spacing-4);
}
.searchRow {
display: flex;
align-items: center;
gap: var(--spacing-4);
--input-background: var(--l3-background);
--input-hover-background: var(--l3-background);
--input-focus-background: var(--l3-background);
--input-border-color: var(--l3-border);
--input-hover-border-color: var(--l3-border);
--input-focus-border-color: var(--l3-border);
--select-trigger-background-color: var(--l3-background);
--select-trigger-hover-background: var(--l3-background);
--select-trigger-focus-background: var(--l3-background);
--select-trigger-border-color: var(--l3-border);
--select-content-background: var(--l3-background);
--select-item-highlight-background: var(--l3-background-hover);
}
.search {
flex: 1 1 auto;
min-width: 0;
--input-width: 100%;
}
.filter {
flex: 0 0 176px;
/* Normalize the library trigger height (2.25rem) to match the input. */
--select-trigger-height: 2rem;
}
.search > *,
.filter > * {
box-sizing: border-box;
}
.searchIcon {
display: inline-flex;
color: var(--l3-foreground);
}
.actionsRow {
display: flex;
gap: var(--spacing-3);
}
.actionButton {
flex: 0 0 auto;
height: 2rem;
}
.list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-4) 0;
overflow-y: auto;
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.sectionHeader {
display: flex;
align-items: baseline;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-2);
margin: 0 0 var(--spacing-1);
border-bottom: 1px solid var(--l2-border);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
padding: var(--spacing-16);
}
.footer {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-4) 0;
border-top: 1px solid var(--l2-border);
}
.hint {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
}
.hintGroup {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.count {
flex: 0 0 auto;
white-space: nowrap;
}

View File

@@ -0,0 +1,240 @@
import { Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { Kbd } from '@signozhq/ui/kbd';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { useCallback, useRef } from 'react';
import { useAuthZDevStore } from '../useAuthZDevStore';
import { useAuthZQueryInvalidation } from '../useAuthZQueryInvalidation';
import { PermissionRow } from './PermissionRow';
import { useAuthZDevModalData } from './useAuthZDevModalData';
import { useModalKeyboard } from './useModalKeyboard';
import styles from './AuthZDevModal.module.css';
export function AuthZDevModal(): JSX.Element | null {
const isModalOpen = useAuthZDevStore((s) => s.isModalOpen);
const closeModal = useAuthZDevStore((s) => s.closeModal);
const observed = useAuthZDevStore((s) => s.observed);
const overrides = useAuthZDevStore((s) => s.overrides);
const cycleOverride = useAuthZDevStore((s) => s.cycleOverride);
const setOverride = useAuthZDevStore((s) => s.setOverride);
const clearAllOverrides = useAuthZDevStore((s) => s.clearAllOverrides);
const grantAll = useAuthZDevStore((s) => s.grantAll);
const denyAll = useAuthZDevStore((s) => s.denyAll);
useAuthZQueryInvalidation(overrides);
const searchInputRef = useRef<HTMLInputElement>(null);
const {
search,
setSearch,
resourceFilter,
setResourceFilter,
observedList,
resourceFilterItems,
filteredPermissions,
groups,
orderedPermissions,
indexByPermission,
hasActiveFilter,
filteredOverrideCount,
overrideCount,
} = useAuthZDevModalData(observed, overrides);
const { selectedIndex, setSelectedIndex } = useModalKeyboard({
permissions: orderedPermissions,
overrides,
onCycle: cycleOverride,
onSetOverride: setOverride,
onClose: closeModal,
searchInputRef,
});
const handleOpenChange = useCallback(
(open: boolean): void => {
if (!open) {
closeModal();
setSelectedIndex(-1);
}
},
[closeModal, setSelectedIndex],
);
const handleGrantAll = useCallback((): void => {
grantAll(hasActiveFilter ? filteredPermissions : undefined);
}, [grantAll, hasActiveFilter, filteredPermissions]);
const handleDenyAll = useCallback((): void => {
denyAll(hasActiveFilter ? filteredPermissions : undefined);
}, [denyAll, hasActiveFilter, filteredPermissions]);
const handleClearAll = useCallback((): void => {
clearAllOverrides(hasActiveFilter ? filteredPermissions : undefined);
}, [clearAllOverrides, hasActiveFilter, filteredPermissions]);
const handleSelectIndex = useCallback(
(index: number) => (): void => {
setSelectedIndex(index);
},
[setSelectedIndex],
);
return (
<DialogWrapper
open={isModalOpen}
onOpenChange={handleOpenChange}
title="AuthZ DevTools"
subTitle="Force permission results locally without touching the backend."
className={styles.modal}
width="wide"
>
<div className={styles.content}>
<div className={styles.header}>
<div className={styles.searchRow}>
<div className={styles.search}>
<Input
ref={searchInputRef}
placeholder="Search permissions..."
value={search}
onChange={(e): void => setSearch(e.target.value)}
prefix={<Search size={14} className={styles.searchIcon} />}
aria-label="Search permissions"
data-testid="authz-dev-search"
/>
</div>
<div className={styles.filter}>
<SelectSimple
items={resourceFilterItems}
value={resourceFilter}
onChange={(value): void => setResourceFilter(value as string)}
testId="authz-dev-resource-filter"
withPortal={false}
/>
</div>
</div>
<div className={styles.actionsRow}>
<Button
className={styles.actionButton}
variant="outlined"
color="success"
size="sm"
onClick={handleGrantAll}
disabled={filteredPermissions.length === 0}
data-testid="authz-dev-grant-all"
>
{hasActiveFilter ? 'Grant filtered' : 'Grant all'}
</Button>
<Button
className={styles.actionButton}
variant="outlined"
color="error"
size="sm"
onClick={handleDenyAll}
disabled={filteredPermissions.length === 0}
data-testid="authz-dev-deny-all"
>
{hasActiveFilter ? 'Deny filtered' : 'Deny all'}
</Button>
<Button
className={styles.actionButton}
variant="outlined"
color="secondary"
size="sm"
onClick={handleClearAll}
disabled={
hasActiveFilter ? filteredOverrideCount === 0 : overrideCount === 0
}
data-testid="authz-dev-clear-all"
>
{hasActiveFilter
? `Clear filtered (${filteredOverrideCount})`
: `Clear all (${overrideCount})`}
</Button>
</div>
</div>
<div className={styles.list} data-testid="authz-dev-permission-list">
{orderedPermissions.length === 0 ? (
<div className={styles.empty}>
<Typography.Text align="center" color="muted">
{observedList.length === 0
? 'No permissions observed yet. Navigate the app to trigger permission checks.'
: 'No permissions match your search.'}
</Typography.Text>
</div>
) : (
groups.map((group) => (
<div key={group.resource} className={styles.section}>
<div className={styles.sectionHeader}>
<Typography.Text as="span" size="medium" weight="semibold">
{group.resource}
</Typography.Text>
<Typography.Text as="span" size="small" color="muted">
{group.items.length}
</Typography.Text>
</div>
{group.items.map((permission) => {
const index = indexByPermission.get(permission) ?? 0;
return (
<PermissionRow
key={permission}
observed={observed[permission]}
override={overrides[permission]}
isSelected={index === selectedIndex}
onSetOverride={setOverride}
onSelect={handleSelectIndex(index)}
/>
);
})}
</div>
))
)}
</div>
<div className={styles.footer}>
<div className={styles.hint}>
<span className={styles.hintGroup}>
<Kbd></Kbd>
<Kbd></Kbd>
<Typography.Text as="span" size="small" color="muted">
navigate
</Typography.Text>
</span>
<span className={styles.hintGroup}>
<Kbd></Kbd>
<Kbd></Kbd>
<Typography.Text as="span" size="small" color="muted">
mode
</Typography.Text>
</span>
<span className={styles.hintGroup}>
<Kbd>1-5</Kbd>
<Typography.Text as="span" size="small" color="muted">
set
</Typography.Text>
</span>
<span className={styles.hintGroup}>
<Kbd>/</Kbd>
<Typography.Text as="span" size="small" color="muted">
search
</Typography.Text>
</span>
<span className={styles.hintGroup}>
<Kbd>Esc</Kbd>
<Typography.Text as="span" size="small" color="muted">
close
</Typography.Text>
</span>
</div>
<Typography.Text size="small" color="muted" className={styles.count}>
{orderedPermissions.length} of {observedList.length} permissions
</Typography.Text>
</div>
</div>
</DialogWrapper>
);
}

View File

@@ -0,0 +1,60 @@
.segmented {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-1);
background: var(--l2-background);
border: 1px solid var(--l3-border);
border-radius: var(--radius-2);
}
.segment {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
height: 22px;
padding: 0 var(--spacing-3);
color: var(--l2-foreground);
background: transparent;
border: none;
border-radius: calc(var(--radius-2) - 1px);
cursor: pointer;
transition:
background 120ms ease,
color 120ms ease;
}
.segment:not(.segmentActive):hover {
color: var(--l2-foreground-hover);
background: var(--l2-background);
}
.segmentIcon {
display: inline-flex;
align-items: center;
}
.segment.optAuto {
color: var(--l1-foreground);
background: var(--l3-background);
}
.segment.optGranted {
color: var(--success-foreground);
background: color-mix(in srgb, var(--accent-forest) 22%, transparent);
}
.segment.optDenied {
color: var(--danger-foreground);
background: color-mix(in srgb, var(--accent-cherry) 22%, transparent);
}
.segment.optDelay {
color: var(--warning-foreground);
background: color-mix(in srgb, var(--accent-amber) 22%, transparent);
}
.segment.optError {
color: var(--danger-foreground);
background: color-mix(in srgb, var(--accent-cherry) 22%, transparent);
}

View File

@@ -0,0 +1,90 @@
import { Check, Clock, RotateCcw, X, Zap } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
import type { OverrideState } from '../types';
import styles from './OverrideControl.module.css';
type OverrideControlProps = {
permission: BrandedPermission;
value: OverrideState;
onSelect: (permission: BrandedPermission, state: OverrideState) => void;
};
type OverrideOption = {
state: OverrideState;
label: string;
icon: React.ReactNode;
activeClassName: string;
};
const OVERRIDE_OPTIONS: OverrideOption[] = [
{
state: 'reset',
label: 'Auto',
icon: <RotateCcw size={13} />,
activeClassName: styles.optAuto,
},
{
state: 'granted',
label: 'Grant',
icon: <Check size={13} />,
activeClassName: styles.optGranted,
},
{
state: 'denied',
label: 'Deny',
icon: <X size={13} />,
activeClassName: styles.optDenied,
},
{
state: 'delay',
label: 'Delay',
icon: <Clock size={13} />,
activeClassName: styles.optDelay,
},
{
state: 'error',
label: 'Error',
icon: <Zap size={13} />,
activeClassName: styles.optError,
},
];
export function OverrideControl({
permission,
value,
onSelect,
}: OverrideControlProps): JSX.Element {
return (
<div className={styles.segmented}>
{OVERRIDE_OPTIONS.map((option) => {
const isActive = value === option.state;
return (
<button
key={option.state}
type="button"
aria-pressed={isActive}
aria-label={option.label}
title={option.label}
className={cx(styles.segment, {
[styles.segmentActive]: isActive,
[option.activeClassName]: isActive,
})}
onClick={(): void => onSelect(permission, option.state)}
data-testid={`override-${option.state}-${permission}`}
>
<span className={styles.segmentIcon}>{option.icon}</span>
{isActive && (
<Typography.Text as="span" size="small" weight="medium">
{option.label}
</Typography.Text>
)}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,68 @@
.permissionRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
padding: var(--spacing-2);
border: 1px solid transparent;
border-radius: var(--radius-2);
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease;
}
.permissionRow:hover {
background: var(--l2-background-hover);
}
/* Overridden rows carry a faint full border in the override color. */
.permissionRow.rowGranted {
border-color: color-mix(in srgb, var(--accent-forest) 45%, transparent);
}
.permissionRow.rowDenied {
border-color: color-mix(in srgb, var(--accent-cherry) 45%, transparent);
}
.permissionRow.rowDelay {
border-color: color-mix(in srgb, var(--accent-amber) 45%, transparent);
}
.permissionRow.rowError {
border-color: color-mix(in srgb, var(--accent-cherry) 45%, transparent);
}
/* Keyboard selection wins over the override border. */
.permissionRow.isSelected {
border-color: var(--primary);
}
.permissionInfo {
display: flex;
flex: 1 1 auto;
align-items: baseline;
gap: var(--spacing-2);
min-width: 0;
}
.relation {
flex: 0 0 auto;
--typography-color: var(--accent-primary);
}
.separator {
flex: 0 0 auto;
}
.object {
flex: 0 1 auto;
min-width: 0;
}
.permissionMeta {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--spacing-5);
}

View File

@@ -0,0 +1,114 @@
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { memo, useCallback, useMemo } from 'react';
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
import { parsePermission } from '../../hooks/useAuthZ/utils';
import type { ObservedPermission, OverrideState } from '../types';
import { OverrideControl } from './OverrideControl';
import styles from './PermissionRow.module.css';
type PermissionRowProps = {
observed: ObservedPermission;
override: OverrideState | undefined;
isSelected: boolean;
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
onSelect: () => void;
};
const ROW_OVERRIDE_CLASSES: Record<OverrideState, string | null> = {
reset: null,
granted: styles.rowGranted,
denied: styles.rowDenied,
delay: styles.rowDelay,
error: styles.rowError,
};
export const PermissionRow = memo(function PermissionRow({
observed,
override,
isSelected,
onSetOverride,
onSelect,
}: PermissionRowProps): JSX.Element {
const currentState = override ?? 'reset';
const { relation, objectId } = useMemo(() => {
const parsed = parsePermission(observed.permission);
const separatorIndex = parsed.object.indexOf(':');
return {
relation: parsed.relation,
objectId:
separatorIndex === -1
? parsed.object
: parsed.object.slice(separatorIndex + 1),
};
}, [observed.permission]);
const handleSetOverride = useCallback(
(permission: BrandedPermission, state: OverrideState): void => {
onSelect();
onSetOverride(permission, state);
},
[onSelect, onSetOverride],
);
let apiColor: BadgeColor = 'secondary';
let apiLabel = 'API ?';
if (observed.apiValue === true) {
apiColor = 'success';
apiLabel = 'API ✓';
} else if (observed.apiValue === false) {
apiColor = 'error';
apiLabel = 'API ✗';
}
return (
<div
className={cx(styles.permissionRow, ROW_OVERRIDE_CLASSES[currentState], {
[styles.isSelected]: isSelected,
})}
data-testid={`permission-row-${observed.permission}`}
>
<div className={styles.permissionInfo}>
<Typography.Text
as="span"
size="small"
weight="medium"
className={styles.relation}
>
{relation}
</Typography.Text>
<Typography.Text
as="span"
size="small"
color="muted"
className={styles.separator}
>
:
</Typography.Text>
<Typography.Text
as="span"
size="small"
truncate={1}
className={styles.object}
>
{objectId}
</Typography.Text>
</div>
<div className={styles.permissionMeta}>
<Badge variant="outline" color={apiColor}>
{apiLabel}
</Badge>
<OverrideControl
permission={observed.permission}
value={currentState}
onSelect={handleSetOverride}
/>
</div>
</div>
);
});

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