Compare commits

..

2 Commits

192 changed files with 1540 additions and 7776 deletions

View File

@@ -155,8 +155,8 @@ querier:
cache_ttl: 168h
# The interval for recent data that should not be cached.
flux_interval: 5m
# The maximum number of queries a single query range request runs at once.
max_concurrent_queries: 8
# The maximum number of concurrent queries for missing ranges.
max_concurrent_queries: 4
# When filtering logs by trace_id, clamp the query window to the trace time
# range with padding to include slightly delayed log exports. Logs only; set
# to 0 to disable.

View File

@@ -4261,8 +4261,6 @@ components:
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- clusterName
- clusterCPU
@@ -4271,7 +4269,6 @@ components:
- clusterMemoryAllocatable
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesClusters:
@@ -4327,8 +4324,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- daemonSetName
- daemonSetCPU
@@ -4340,7 +4335,6 @@ components:
- desiredNodes
- currentNodes
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesDaemonSets:
@@ -4396,8 +4390,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- deploymentName
- deploymentCPU
@@ -4409,7 +4401,6 @@ components:
- desiredPods
- availablePods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesDeployments:
@@ -4542,8 +4533,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
successfulPods:
type: integer
required:
@@ -4559,7 +4548,6 @@ components:
- failedPods
- successfulPods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesJobs:
@@ -4650,14 +4638,11 @@ components:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- namespaceName
- namespaceCPU
- namespaceMemory
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesNamespaces:
@@ -4723,14 +4708,11 @@ components:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- nodeName
- condition
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- nodeCPU
- nodeCPUAllocatable
- nodeMemory
@@ -4776,64 +4758,6 @@ components:
- failed
- unknown
type: object
InframonitoringtypesPodCountsByStatus:
properties:
completed:
type: integer
containerCannotRun:
type: integer
containerCreating:
type: integer
crashLoopBackOff:
type: integer
createContainerConfigError:
type: integer
errImagePull:
type: integer
error:
type: integer
evicted:
type: integer
failed:
type: integer
imagePullBackOff:
type: integer
nodeAffinity:
type: integer
nodeLost:
type: integer
oomKilled:
type: integer
pending:
type: integer
running:
type: integer
shutdown:
type: integer
unexpectedAdmissionError:
type: integer
unknown:
type: integer
required:
- pending
- running
- failed
- unknown
- crashLoopBackOff
- imagePullBackOff
- errImagePull
- createContainerConfigError
- containerCreating
- oomKilled
- completed
- error
- containerCannotRun
- evicted
- nodeAffinity
- nodeLost
- shutdown
- unexpectedAdmissionError
type: object
InframonitoringtypesPodPhase:
enum:
- pending
@@ -4864,8 +4788,6 @@ components:
type: number
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
podMemory:
format: double
type: number
@@ -4877,11 +4799,6 @@ components:
type: number
podPhase:
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
podRestarts:
format: int64
type: integer
podStatus:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
podUID:
type: string
required:
@@ -4894,34 +4811,9 @@ components:
- podMemoryLimit
- podPhase
- podCountsByPhase
- podStatus
- podCountsByStatus
- podRestarts
- podAge
- meta
type: object
InframonitoringtypesPodStatus:
enum:
- pending
- running
- failed
- unknown
- crashloopbackoff
- imagepullbackoff
- errimagepull
- createcontainerconfigerror
- containercreating
- oomkilled
- completed
- error
- containercannotrun
- evicted
- nodeaffinity
- nodelost
- shutdown
- unexpectedadmissionerror
- no_data
type: string
InframonitoringtypesPods:
properties:
endTimeBeforeRetention:
@@ -5220,8 +5112,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
statefulSetCPU:
format: double
type: number
@@ -5253,7 +5143,6 @@ components:
- desiredPods
- currentPods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesStatefulSets:

View File

@@ -1,149 +0,0 @@
# Authz
SigNoz uses [OpenFGA](https://openfga.dev/), a relationship-based access control (ReBAC) system, to authorize every request. Transactions are never attached to users directly — they are attached to **roles** (as relationship tuples in OpenFGA), and users or service accounts (principals) are made **assignees** of those roles. The central interface is `AuthZ` in [pkg/authz/authz.go](/pkg/authz/authz.go), backed by an embedded OpenFGA server in [pkg/authz/openfgaserver](/pkg/authz/openfgaserver/server.go).
As a feature author, you will rarely touch OpenFGA directly. You interact with two layers:
1. **The registries** in [pkg/types/coretypes](/pkg/types/coretypes) — where every resource, verb, and managed-role permission is declared in code.
2. **The route wiring** in [pkg/apiserver/signozapiserver](/pkg/apiserver/signozapiserver) — where each route declares what resource it touches and which verb it needs, and the middleware turns that into a check.
## What are the building blocks?
### Type
A `Type` is the coarse FGA type of a resource. The set is finite and defined in [pkg/types/coretypes/registry_type.go](/pkg/types/coretypes/registry_type.go): `user`, `serviceaccount`, `anonymous`, `role`, `organization`, `metaresource`, and `telemetryresource`. Each type carries a selector regex (what a valid ID looks like) and the verbs allowed on it.
Almost every feature resource is a `metaresource` (dashboards, rules, pipelines, ...) or a `telemetryresource` (logs, traces, metrics). You should almost never need a new type.
### Verb
A `Verb` is the action being authorized. All verbs are defined in [pkg/types/coretypes/registry_verb.go](/pkg/types/coretypes/registry_verb.go): `create`, `read`, `update`, `delete`, `list`, `assignee`, `attach`, and `detach`.
- `assignee` is special: it is the membership relation between a subject and a role ("user X is an assignee of role Y"), not an action a route checks for.
- `attach`/`detach` authorize linking two resources together (e.g. assigning a role to a service account).
### Kind
A `Kind` is the fine-grained name of your resource — `dashboard`, `rule`, `quick-filter`. Kinds are registered in [pkg/types/coretypes/registry_kind.go](/pkg/types/coretypes/registry_kind.go). A kind rides on top of an existing type, so adding one does **not** require any OpenFGA schema change.
### Resource
A `Resource` ties a type and a kind together and knows how to render itself as an FGA object string. The interface lives in [pkg/types/coretypes/resource.go](/pkg/types/coretypes/resource.go):
```go
type Resource interface {
Type() Type
Kind() Kind
Prefix(orgId valuer.UUID) string // metaresource:organization/<orgID>/dashboard
Object(orgId valuer.UUID, selector string) string
Scope(verb Verb) string // dashboard:read
AllowedVerbs() []Verb
}
```
All resources are registered in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) using constructors like `NewResourceMetaResource(KindDashboard)` or `NewResourceTelemetryResource(KindLogs)`.
### Selector
A `Selector` identifies *which* instance(s) of a resource a check is about — a UUID, a role name, or the wildcard `*`. A `SelectorFunc` maps the extracted resource ID to selectors at request time. Two prebuilt ones cover most routes ([pkg/types/coretypes/selector.go](/pkg/types/coretypes/selector.go)):
- `WildcardSelector` — the check is against all instances of the resource (`create`, `list`).
- `IDSelector` — the check is against the specific instance *or* the wildcard (`read`, `update`, `delete` of one object). A subject authorized on `*` is authorized on every instance.
When the ID in the request is not what FGA needs (e.g. routes receive a role UUID but FGA objects use role names), write a custom `SelectorFunc` — see `roleSelector` in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
### Roles, transactions, and tuples
SigNoz ships four managed roles, declared in [pkg/types/coretypes/registry_managed_role.go](/pkg/types/coretypes/registry_managed_role.go): `signoz-admin`, `signoz-editor`, `signoz-viewer`, and `signoz-anonymous`. Their permissions are declared in code as `Transaction`s (a verb on an object) in `ManagedRoleToTransactions` — this map is the single source of truth for what each managed role can do.
At organization bootstrap, `CreateManagedRoles` and `CreateManagedUserRoleTransactions` (see [pkg/authz/authz.go](/pkg/authz/authz.go)) persist the role rows and write one OpenFGA tuple per transaction, linking `role:organization/<orgID>/role/<name>#assignee` to each permitted object. Users and service accounts are then granted roles via `Grant`/`Revoke`, which writes `assignee` tuples. Custom roles (enterprise) are managed through the roles API in [pkg/authz/signozauthzapi/handler.go](/pkg/authz/signozauthzapi/handler.go).
### Schema
The OpenFGA authorization model is a hand-written DSL, embedded at build time: [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) for community and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) for enterprise. The community model only supports role assignment; the enterprise model defines per-verb relations on every type, enabling genuine per-resource checks. This split is why `CheckWithTupleCreation` behaves differently per edition (see [How does a check work at runtime?](#how-does-a-check-work-at-runtime)). You only touch these files when introducing a brand-new **type** — never for a new kind.
## How do I add authz to my feature?
### 1. Register the kind
Add your kind in [pkg/types/coretypes/registry_kind.go](/pkg/types/coretypes/registry_kind.go) and append it to `Kinds`:
```go
KindThing = MustNewKind("thing")
```
### 2. Register the resource
Add the resource in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) and append it to `Resources`:
```go
ResourceMetaResourceThing = NewResourceMetaResource(KindThing)
```
Pass an explicit verb list to `NewResourceMetaResource` only if your resource supports fewer verbs than the type default.
### 3. Grant permissions to managed roles
Decide what each managed role can do with your resource and add the transactions in [pkg/types/coretypes/registry_managed_role.go](/pkg/types/coretypes/registry_managed_role.go):
```go
// thing — editors manage, viewers read
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindThing}, WildCardSelectorString)},
```
The convention so far: admin gets everything, editor gets CRUD on day-to-day observability resources, viewer gets `read`/`list`, anonymous gets nothing (except public dashboards).
### 4. Wire the route
Register the route in [pkg/apiserver/signozapiserver](/pkg/apiserver/signozapiserver), wrapping your handler with `CheckResources` and declaring what the route touches via a `ResourceDef` ([pkg/http/handler/resourcedef.go](/pkg/http/handler/resourcedef.go)). A complete example from [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go):
```go
router.Handle("/api/v1/service_accounts", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccount",
// ...
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost)
```
The pieces:
- **`CheckResources(handlerFn, roles...)`** — the resource-aware authorization wrapper from [pkg/http/middleware/authz.go](/pkg/http/middleware/authz.go). The role list is the community-edition fallback: which managed roles may call this route when per-resource checks are unavailable.
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.
### 5. Backfill existing organizations (only if needed)
Managed-role tuples are written from the registry at organization creation, so **new resources need no migration for new organizations**. If existing organizations must get the new permissions, add a migration in [pkg/sqlmigration](/pkg/sqlmigration) that inserts the tuples — see [083_add_role_crud_tuples.go](/pkg/sqlmigration/083_add_role_crud_tuples.go) for the pattern.
## How does a check work at runtime?
1. The resource middleware ([pkg/http/middleware/resource.go](/pkg/http/middleware/resource.go)) runs on every request. It reads the matched handler's `ResourceDef`s, extracts the resource IDs from path/body, and stores the resolved resources in the request context.
2. `CheckResources` reads them back, runs each `SelectorFunc`, and calls `AuthZ.CheckWithTupleCreation(ctx, claims, orgID, relation, resource, selectors, roleSelectors)`.
3. What happens next depends on the edition:
- **Community** ([pkg/authz/openfgaserver/server.go](/pkg/authz/openfgaserver/server.go)) ignores the resource and selectors and only checks whether the subject is an `assignee` of one of the allowed roles — a plain role gate.
- **Enterprise** ([ee/authz/openfgaserver/server.go](/ee/authz/openfgaserver/server.go)) builds tuples via `authtypes.NewTuples` — subject `user:organization/<orgID>/user/<userID>`, relation `create`, object `serviceaccount:organization/<orgID>/serviceaccount/*` — and batch-checks them against OpenFGA: genuine per-resource authorization, including custom roles.
Because both paths go through the same middleware and the same `ResourceDef` declarations, a route wired once works correctly in both editions.
## What should I remember?
- Declare authz in the registries ([pkg/types/coretypes](/pkg/types/coretypes)), not in migrations — tuples for new organizations are derived from code at bootstrap.
- A new kind never needs an OpenFGA schema change; only a new type does, and then **both** [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) must be updated together.
- Prefer `CheckResources` + `ResourceDef` over the coarse `ViewAccess`/`EditAccess`/`AdminAccess` gates for new routes.
- Use `WildcardSelector` for `create`/`list`, `IDSelector` for instance operations, and a custom `SelectorFunc` when the request ID is not the FGA selector.
- Linking routes check **both** sides: peers via `AttachDetachSiblingResourceDef` (same verb on both), parent-child via a `BasicResourceDef` on the child (`create`/`delete`) paired with an `AttachDetachParentChildResourceDef` on the parent (`attach`/`detach`).
- Changing `ManagedRoleToTransactions` only affects organizations created afterwards — add a [pkg/sqlmigration](/pkg/sqlmigration) migration to backfill existing ones.

View File

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

View File

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

View File

@@ -29,7 +29,6 @@ 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

@@ -5704,81 +5704,6 @@ export interface InframonitoringtypesPodCountsByPhaseDTO {
unknown: number;
}
export interface InframonitoringtypesPodCountsByStatusDTO {
/**
* @type integer
*/
completed: number;
/**
* @type integer
*/
containerCannotRun: number;
/**
* @type integer
*/
containerCreating: number;
/**
* @type integer
*/
crashLoopBackOff: number;
/**
* @type integer
*/
createContainerConfigError: number;
/**
* @type integer
*/
errImagePull: number;
/**
* @type integer
*/
error: number;
/**
* @type integer
*/
evicted: number;
/**
* @type integer
*/
failed: number;
/**
* @type integer
*/
imagePullBackOff: number;
/**
* @type integer
*/
nodeAffinity: number;
/**
* @type integer
*/
nodeLost: number;
/**
* @type integer
*/
oomKilled: number;
/**
* @type integer
*/
pending: number;
/**
* @type integer
*/
running: number;
/**
* @type integer
*/
shutdown: number;
/**
* @type integer
*/
unexpectedAdmissionError: number;
/**
* @type integer
*/
unknown: number;
}
export interface InframonitoringtypesClusterRecordDTO {
/**
* @type number
@@ -5810,7 +5735,6 @@ export interface InframonitoringtypesClusterRecordDTO {
meta: InframonitoringtypesClusterRecordDTOMeta;
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export enum InframonitoringtypesResponseTypeDTO {
@@ -5914,7 +5838,6 @@ export interface InframonitoringtypesDaemonSetRecordDTO {
*/
meta: InframonitoringtypesDaemonSetRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesDaemonSetsDTO {
@@ -5992,7 +5915,6 @@ export interface InframonitoringtypesDeploymentRecordDTO {
*/
meta: InframonitoringtypesDeploymentRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesDeploymentsDTO {
@@ -6159,7 +6081,6 @@ export interface InframonitoringtypesJobRecordDTO {
*/
meta: InframonitoringtypesJobRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type integer
*/
@@ -6213,7 +6134,6 @@ export interface InframonitoringtypesNamespaceRecordDTO {
*/
namespaceName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesNamespacesDTO {
@@ -6280,7 +6200,6 @@ export interface InframonitoringtypesNodeRecordDTO {
*/
nodeName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesNodesDTO {
@@ -6318,27 +6237,6 @@ export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
export type InframonitoringtypesPodRecordDTOMeta =
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
export enum InframonitoringtypesPodStatusDTO {
pending = 'pending',
running = 'running',
failed = 'failed',
unknown = 'unknown',
crashloopbackoff = 'crashloopbackoff',
imagepullbackoff = 'imagepullbackoff',
errimagepull = 'errimagepull',
createcontainerconfigerror = 'createcontainerconfigerror',
containercreating = 'containercreating',
oomkilled = 'oomkilled',
completed = 'completed',
error = 'error',
containercannotrun = 'containercannotrun',
evicted = 'evicted',
nodeaffinity = 'nodeaffinity',
nodelost = 'nodelost',
shutdown = 'shutdown',
unexpectedadmissionerror = 'unexpectedadmissionerror',
no_data = 'no_data',
}
export interface InframonitoringtypesPodRecordDTO {
/**
* @type object,null
@@ -6365,7 +6263,6 @@ export interface InframonitoringtypesPodRecordDTO {
*/
podCPURequest: number;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type number
* @format double
@@ -6382,12 +6279,6 @@ export interface InframonitoringtypesPodRecordDTO {
*/
podMemoryRequest: number;
podPhase: InframonitoringtypesPodPhaseDTO;
/**
* @type integer
* @format int64
*/
podRestarts: number;
podStatus: InframonitoringtypesPodStatusDTO;
/**
* @type string
*/
@@ -6705,7 +6596,6 @@ export interface InframonitoringtypesStatefulSetRecordDTO {
*/
meta: InframonitoringtypesStatefulSetRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type number
* @format double

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#1287b1" viewBox="0 0 24 24"><title>Apache Cassandra</title><path d="M10.374 10.53a3 3 0 0 1-.428-.222l.555.143c0 .02-.01.036-.01.055l-.117.025zm-.283 1.506-.315.253.852-1.079-1.078.391c.002.017.009.033.009.05a.57.57 0 0 1-.184.42q.154.327.375.616a3.2 3.2 0 0 1 .34-.651zm.717-2.347-.652-.82a.43.43 0 0 1-.506.162c-.054.073-.083.162-.13.24l1.258.463zm-1.666.444c-.07.314-.087.637-.05.956a.57.57 0 0 1 .451.475l.946-.606c-.067-.022-.126-.06-.191-.088l-1.119-.08.64-.14a3.2 3.2 0 0 1-.668-.554zM20.1 11.648c-.164.202.833 1.022.833 1.022s-1.654-1.022-2.234-.72c-.278.144.574.811 1.175 1.242-.428-.274-.982-.571-1.175-.408-.328.277 1.565 2.549 1.565 2.549s-2.145-2.322-2.36-2.209c-.214.114.593 1.224.593 1.224s-1.06-1.16-1.35-.959c-.29.202 1.514 3.218 1.514 3.218s-1.956-3.091-2.763-2.574c1.268 2.782.795 3.18.795 3.18s-.162-2.839-1.742-2.764c-.795.038.379 2.12.379 2.12s-1.08-1.902-1.8-1.864c1.326 2.51.854 3.53.854 3.53s.219-2.143-1.58-3.336c.682.606-.427 3.336-.427 3.336s.976-4.023-.719-3.256c-.268.121-.019 2.007-.019 2.007s-.34-2.158-.851-2.045c-.298.066-1.893 2.99-1.893 2.99s1.306-3.16.908-3.027c-.29.096-.833 1.4-.833 1.4s.265-1.287 0-1.363c-.264-.075-1.74 1.363-1.74 1.363s1.097-1.287.908-1.552c-.287-.402-.623-.42-1.022-.265-.581.226-1.363 1.287-1.363 1.287s.78-1.074.643-1.476c-.219-.647-2.46 1.249-2.46 1.249s1.325-1.25 1.022-1.514c-.303-.265-1.947-.183-2.46-.185-1.515-.004-2.039-.36-2.498-.724 1.987.997 3.803-.151 6.094.494l.21.06c-1.3-.558-2.144-1.378-2.226-2.354-.036-.416.074-.827.297-1.222.619-.4 1.29-.773 2.06-1.095a4 4 0 0 0-.064.698c0 2.44 2.203 4.417 4.92 4.417s4.92-1.977 4.92-4.417c0-.45-.083-.881-.223-1.29 1.431.404 2.45.968 3.132 1.335.022.092.045.184.053.279.024.274-.018.547-.11.814.095-.147.198-.288.28-.445.367-.997 1.855.227 1.855.227s-1.085-.454-1.06-.24c.026.215 1.628.96 1.628.96s-1.45-.455-1.362-.114c.088.34 1.817 1.703 1.817 1.703s-1.956-1.489-2.12-1.287zm-7.268 2.65.042-.008-.06.01zM9.256 9.753c.12.13.26.234.396.343l.927-.029-1.064-.788c-.093.154-.195.303-.26.474Zm10.62 3.44c.3.215.54.373.54.373s-.24-.181-.54-.374zM7.507 8.617c-.14.229-.214.492-.215.76a3.99 3.99 0 0 0 2.358 3.64q0-.008.003-.014a3.2 3.2 0 0 1-.58-.788c-.648.099-.926-.794-.336-1.08a3.2 3.2 0 0 1 .138-1.388 3.16 3.16 0 0 1-.52-1.36q-.444.105-.848.23m1.488.82q.163-.36.402-.661a.435.435 0 0 1 .568-.557c.077-.059.166-.099.248-.15a16 16 0 0 0-1.727.284c.114.388.272.76.509 1.084m2.285 3.928c1.4 0 2.633-.723 3.344-1.816a3.4 3.4 0 0 0-1.265-.539l-.297-.023.916.9-1.197-.467.704 1.078-1.074-.832-.012.006.347 1.278-.596-1.134-.098 1.33-.401-1.326-.472 1.261.114-1.359-.015-.008-.814 1.154.286-1.067c-.34.322-.605.713-.781 1.146q.143.153.303.29.484.126 1.008.128m10.145-4.434c.971-.567 1.716-1.955 1.716-1.955s-1.893 1.955-3.205 1.665c1.186-.934 1.766-2.549 1.766-2.549s-1.506 2.325-2.448 2.423c1.086-.959 1.54-2.322 1.54-2.322s-1.237 1.817-2.196 1.944c1.287-1.161 1.338-1.893 1.338-1.893s-1.781 2.302-2.499 1.943c.858-.934 1.439-2.12 1.439-2.12s-1.489 2.019-1.893 1.69c-.277-.05.454-.958.454-.958s-.908.807-1.16.606c.454-.278 1.236-1.64 1.236-1.64S16 7.505 15.621 7.304l.731-1.483s-.73 1.483-1.715 1.23c.454-.58.63-1.112.63-1.112s-.756 1.213-1.69.885c-.22-.077.273-.635.273-.635s-.626.61-1.055.534c-.43-.076.025-.858.025-.858s-.757 1.186-.908 1.136.075-.833.075-.833-.555.908-.858.858 0-.934 0-.934-.328.984-.58.909c-.252-.076-.303-.656-.303-.656s-.068.788-.429.858c-2.725.53-5.728 1.69-9.489 5.45C3.887 10.738 5.3 7.91 11.962 7.659c5.044-.191 7.399 2.137 8.177 2.17C22.51 9.93 24 7.633 24 7.633s-1.489 1.716-2.574 1.3zm-7.74.872-.608.464v.001l.054.003a3.4 3.4 0 0 0 .554-.468m1.583-.426c0-.536-.237-.929-.594-1.217a3.2 3.2 0 0 1-.165.825.393.393 0 0 1-.328.681c-.154.233-.34.445-.549.63l.661.034-.995.237c-.025.018-.045.041-.07.058a3.2 3.2 0 0 1 1.536.691c.32-.574.504-1.235.504-1.94zM10.99 7.996a3.5 3.5 0 0 0-.785.46.43.43 0 0 1-.013.357l.885.643.023-.016-.36-1.262.627 1.12c.018-.006.04-.006.058-.011l-.02-1.251.398 1.163.477-1.15.016 1.268.012.007.713-1.005-.363 1.218.009.01 1.04-.69-.759 1.05.002.005.95-.34.041-.045a.395.395 0 0 1 .394-.632 3.4 3.4 0 0 0 .27-.784 14 14 0 0 0-2.798-.168c-.286.011-.55.033-.817.053"/></svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="48 -2.25 262.5 364"><defs><style>.cls-1{fill:#326ce5}.cls-3{fill:none}</style></defs><path d="M59.724 97.778a10.183 10.183 0 0 1 0-17.073l114.07-74.16a10.18 10.18 0 0 1 11.1 0l114.07 74.16a10.183 10.183 0 0 1 0 17.073l-114.07 74.16a10.18 10.18 0 0 1-11.1 0z" class="cls-1"/><path fill="#c1d2f7" d="M197.356 110.866h7.912a6.003 6.003 0 0 0 5.2-9.005l-25.924-44.902a6.004 6.004 0 0 0-10.399 0l-25.924 44.902a6.003 6.003 0 0 0 5.2 9.005h7.912a6.003 6.003 0 0 1 6.004 6.003v51.257l5.31 3.452a12.29 12.29 0 0 0 13.395 0l5.31-3.452v-51.257a6.003 6.003 0 0 1 6.004-6.003"/><path d="M173.793 353.271a10.1 10.1 0 0 0 3.457 1.402 119 119 0 0 0-6.623-3.46zm-6.456-161.195-11.316 7.356a111 111 0 0 0 11.316 6.6zm24.015 23.716c9.739 3.115 19.813 5.648 30.11 8.186 10.92 2.692 21.973 5.427 32.818 9.009l-35.416-23.025a216 216 0 0 1-27.512-9.41zm0 62.191v.586c0 2.57-2.688 4.652-6.003 4.652H173.34c-3.315 0-6.003-2.083-6.003-4.652v-6.605c-24.906-6.402-49.873-14.485-70.86-33.82l-14.529 9.445c22.468 22.05 50.28 28.933 79.715 36.189 27.178 6.698 55.18 13.651 78.685 33.442l14.68-9.544c-18.532-16.504-40.41-23.687-63.676-29.693M93.315 300.95l38.908 25.295c20.064 5.113 40.2 11.248 58.31 23.36l14.901-9.687c-19.752-13.672-42.55-19.313-66.419-25.197-15.287-3.767-30.835-7.616-45.7-13.771m74.022-82.254a124.3 124.3 0 0 1-21.534-12.62l-14.805 9.625a124.5 124.5 0 0 0 36.339 18.708zm51.41 16.294c-9.106-2.245-18.304-4.524-27.395-7.299v13.682q5.051 1.286 10.177 2.538c29.934 7.379 60.888 15.01 85.708 39.829.61.61 1.162 1.24 1.752 1.857l9.974-6.484a10.1 10.1 0 0 0 3.475-3.8 101 101 0 0 0-3.289-3.486c-22.585-22.585-50.669-29.508-80.402-36.837m-19.933 19.933q-3.722-.918-7.462-1.855v13.21c25.817 6.561 51.803 14.688 73.455 35.04l14.521-9.44-.11-.118c-22.586-22.585-50.67-29.509-80.404-36.837m-31.477-8.587c-16.28-5.278-32.118-12.397-46.344-24.131l-14.682 9.545c17.87 15.557 38.784 22.673 61.026 28.5z" class="cls-3"/><path d="M73.24 254.96c-.348-.348-.658-.71-1-1.06l-12.514 8.136a10 10 0 0 0-1.644 1.397c1.053 1.157 2.115 2.31 3.245 3.44 22.585 22.585 50.67 29.509 80.403 36.837 25.343 6.247 51.401 12.717 73.865 29.603l14.806-9.626c-20.865-16.42-45.53-22.509-71.453-28.898-29.934-7.379-60.888-15.01-85.708-39.83" class="cls-3"/><path d="m218.864 209.962-27.512-17.886v8.476a216 216 0 0 0 27.512 9.41m-27.512 5.83v11.9c9.091 2.774 18.289 5.053 27.395 7.298 29.733 7.329 57.817 14.252 80.402 36.837a101 101 0 0 1 3.29 3.486 10.193 10.193 0 0 0-3.476-13.277l-44.683-29.05c-10.845-3.58-21.898-6.316-32.818-9.008-10.297-2.538-20.371-5.071-30.11-8.186m-24.015-9.76a111 111 0 0 1-11.316-6.6l-10.218 6.644a124.3 124.3 0 0 0 21.534 12.62zm34.192 37.88q-5.125-1.264-10.177-2.539v11.695q3.737.938 7.462 1.855c29.733 7.328 57.818 14.252 80.403 36.837.039.039.073.079.111.118l9.66-6.28c-.589-.618-1.142-1.248-1.751-1.858-24.82-24.82-55.774-32.45-85.708-39.829m-34.192-9.503a124.5 124.5 0 0 1-36.34-18.708l-10.004 6.504c14.226 11.734 30.064 18.853 46.344 24.13zm0 25.842c-22.242-5.828-43.156-12.944-61.026-28.5l-9.834 6.392c20.987 19.336 45.954 27.42 70.86 33.821zm24.015 17.732c23.266 6.006 45.144 13.19 63.675 29.693l9.78-6.358c-21.652-20.352-47.638-28.479-73.455-35.04zM81.948 247.59l-9.707 6.311c.341.35.652.712 1 1.06 24.82 24.82 55.773 32.45 85.707 39.829 25.922 6.39 50.588 12.478 71.452 28.898l9.948-6.467c-23.505-19.791-51.507-26.744-78.685-33.442-29.436-7.256-57.247-14.138-79.715-36.189m-20.621 19.284c-1.13-1.13-2.192-2.283-3.245-3.44a10.154 10.154 0 0 0 1.644 15.68l33.589 21.837c14.865 6.155 30.413 10.004 45.7 13.771 23.869 5.884 46.667 11.525 66.419 25.197l10.16-6.605c-22.463-16.886-48.521-23.356-73.864-29.603-29.733-7.328-57.818-14.252-80.403-36.837m109.3 84.34a119 119 0 0 1 6.623 3.46 10.16 10.16 0 0 0 7.645-1.402l5.639-3.666c-18.11-12.112-38.247-18.247-58.311-23.36z" class="cls-1"/></svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><title>PlanetScale</title><path d="M0 12C0 5.373 5.373 0 12 0c4.873 0 9.067 2.904 10.947 7.077l-15.87 15.87a12 12 0 0 1-1.935-1.099L14.99 12H12l-8.485 8.485A11.96 11.96 0 0 1 0 12m12.004 12L24 12.004C23.998 18.628 18.628 23.998 12.004 24"/></svg>

Before

Width:  |  Height:  |  Size: 326 B

View File

@@ -20,9 +20,9 @@
padding: 0px 8px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
color: var(--l2-foreground);
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
color: var(--input-with-label-color, var(--l2-foreground));
display: flex;
justify-content: flex-start;
@@ -35,21 +35,54 @@
min-width: 150px;
font-family: 'Space Mono', monospace !important;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
--input-border-radius: 0px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
color: var(--input-with-label-color, var(--l2-foreground));
border-right: none;
border-left: none;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
font-size: 12px !important;
line-height: 27px;
line-height: 25px;
position: relative;
&:hover,
&:focus {
z-index: 1;
}
.ant-select-selector {
position: relative;
border-radius: inherit;
}
.ant-select:hover .ant-select-selector,
.ant-select-focused .ant-select-selector {
z-index: 1;
}
&.input__has-label-after {
margin-left: -1px;
.ant-select-selector {
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
&.input__has-close-button {
.ant-select-selector {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
~ .close-btn {
margin-left: -1px;
}
}
&::placeholder {
color: var(--l2-foreground) !important;
color: var(--input-with-label-color, var(--l3-foreground)) !important;
font-size: 12px !important;
}
&[type='number']::-webkit-inner-spin-button,
@@ -63,25 +96,35 @@
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
height: 38px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
height: 100%;
width: 38px;
position: relative;
&:hover,
&:focus {
z-index: 2;
}
}
&.labelAfter {
.input {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-radius: 2px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
.ant-select-selector {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.label {
border-left: none;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
border-radius: 0px 2px 2px 0px;
}
}
}

View File

@@ -45,7 +45,10 @@ function InputWithLabel({
>
{!labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
<Input
className="input"
className={cx('input', {
'input__has-label-after': !labelAfter,
'input__has-close-button': !!onClose,
})}
placeholder={placeholder}
type={type}
value={inputValue}

View File

@@ -80,8 +80,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -101,7 +101,8 @@
top: 12px;
width: 6px;
height: 6px;
border-left: 6px dotted var(--l1-border);
border-left: 6px dotted
var(--query-builder-v2-border-color, var(--l2-border));
}
/* Horizontal line pointing from vertical to the item */
@@ -114,8 +115,8 @@
height: 1px;
background: repeating-linear-gradient(
to right,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -241,7 +242,8 @@
top: 12px;
width: 6px;
height: 6px;
border-left: 6px dotted var(--l1-border);
border-left: 6px dotted
var(--query-builder-v2-border-color, var(--l2-border));
}
/* Horizontal line pointing from vertical to the item */
@@ -254,8 +256,8 @@
height: 1px;
background: repeating-linear-gradient(
to right,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -273,6 +275,16 @@
line-height: 16px; /* 128.571% */
resize: none;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
&:placeholder {
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
}
}
.formula-legend {
@@ -282,15 +294,42 @@
.ant-input-group-addon {
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
background: var(--l2-background);
color: var(--l2-foreground);
background: var(
--query-builder-v2-background-color,
var(--l2-background)
);
color: var(--query-builder-v2-color, var(--l2-foreground));
font-size: 12px;
font-weight: 300;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
position: relative;
}
.ant-input {
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
height: 36px;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
position: relative;
margin-left: -1px;
&:hover,
&:focus {
z-index: 1;
border-color: var(--internal-ant-border-color-hover);
}
&:placeholder {
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
}
}
}
}
@@ -323,8 +362,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -395,8 +434,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -412,7 +451,7 @@
min-width: 120px;
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--l1-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
@@ -457,13 +496,16 @@
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l1-background) !important;
height: 34px !important;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
height: 36px !important;
box-sizing: border-box !important;
.ant-select-selection-item {
color: var(--l1-foreground);
color: var(--query-builder-v2-color, var(--l1-foreground));
}
}

View File

@@ -92,6 +92,11 @@
.ant-select {
width: 100%;
.ant-select-selector {
min-height: 36px;
}
.ant-select-selection-search-input {
min-width: max-content !important;
max-width: 100% !important;
@@ -100,9 +105,12 @@
.ant-select-selector {
border-radius: 2px;
border: 1.005px solid var(--l1-border);
background: var(--l1-background);
color: var(--l1-foreground);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground));
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
@@ -123,6 +131,7 @@
.input {
flex: initial;
width: 100px !important;
min-height: 36px;
}
}
}

View File

@@ -8,9 +8,9 @@
.ant-select-selection-search-input {
font-size: 12px !important;
line-height: 27px;
line-height: 25px;
&::placeholder {
color: var(--l2-foreground) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
font-size: 12px !important;
}
}
@@ -22,9 +22,12 @@
.ant-select-selector {
width: 100%;
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l1-background);
color: var(--l1-foreground);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground));
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
@@ -33,36 +36,49 @@
min-height: 36px;
.ant-select-selection-placeholder {
color: var(--l2-foreground) !important;
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
font-size: 12px !important;
}
}
}
.ant-select-dropdown {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.qb-select-popover.ant-select-dropdown {
border-radius: 4px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.ant-select-item {
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
.ant-select-item {
color: var(--query-builder-v2-color, var(--l2-foreground));
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
&:hover,
&.ant-select-item-option-active {
background: var(--l3-background) !important;
}
&:not(:last-of-type) {
margin-bottom: 4px;
}
&.ant-select-item-option-selected {
background: var(--l3-background) !important;
border: 1px solid var(--l1-border);
font-weight: 600;
}
&:hover,
&.ant-select-item-option-active {
background: var(
--query-builder-v2-selected-background-color,
var(--l3-background)
) !important;
}
&.ant-select-item-option-selected {
background: var(
--query-builder-v2-selected-background-color,
var(--l3-background)
) !important;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
font-weight: 600;
}
}
}

View File

@@ -142,6 +142,7 @@ export const MetricsSelect = memo(function MetricsSelect({
{signalSourceChangeEnabled && (
<Select
className="source-selector"
popupClassName="qb-select-popover"
placeholder="Source"
options={SOURCE_OPTIONS}
value={source}

View File

@@ -1,8 +1,23 @@
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
.query-add-ons {
width: 100%;
--toggle-group-secondary-bg: var(
--query-builder-v2-toggle-group-background-color,
var(--l1-background-hover)
);
--toggle-group-secondary-border: var(
--query-builder-v2-toggle-group-border-color,
var(--l2-border)
);
--toggle-group-secondary-active-bg: var(
--query-builder-v2-toggle-group-active-background-color,
var(--l1-background)
);
--toggle-group-secondary-bg-hover: var(
--query-builder-v2-toggle-group-background-color-hover,
var(--l2-background)
);
.add-on-tab-title {
display: flex;
align-items: center;
@@ -29,32 +44,33 @@
font-style: normal;
font-weight: var(--font-weight-normal);
color: var(--l2-foreground);
color: var(--query-builder-v2-color, var(--l2-foreground));
}
> button {
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-left: none;
min-width: 120px;
height: 36px;
line-height: 36px;
&:first-child {
border-left: 1px solid var(--l1-border);
border-left: 1px solid
var(--query-builder-v2-border-color, var(--l2-border));
}
&::before {
background: var(--l1-border);
background: var(--query-builder-v2-border-color, var(--l2-border));
}
&[data-state='on'] {
color: var(--text-robin-500);
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
display: none;
&::before {
background: var(--l1-border);
background: var(--query-builder-v2-border-color, var(--l2-border));
}
}
}
@@ -65,7 +81,7 @@
height: 30px;
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
@@ -78,10 +94,13 @@
align-items: center;
.having-filter-select-container {
position: relative;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-start;
background: var(--query-builder-v2-background-color, var(--l2-background));
padding-right: 38px;
.having-filter-select-editor {
border-radius: 2px;
@@ -106,15 +125,17 @@
}
.cm-content {
border-radius: 2px;
border: 1px solid var(--l1-border);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-left-width: 0px;
border-right-width: 0px;
padding: 0px !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&:focus-within {
border-color: var(--l1-border);
border-color: var(--query-builder-v2-border-color, var(--l2-border));
}
}
@@ -218,17 +239,32 @@
}
.cm-line {
line-height: 36px !important;
min-height: 34px;
line-height: 32px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
::-moz-selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
@@ -237,8 +273,11 @@
}
.chip-decorator {
background: var(--l3-background) !important;
color: var(--l1-foreground) !important;
background: var(
--query-builder-v2-chip-decorator-background-color,
var(--l3-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
border-radius: 4px;
padding: 2px 4px;
margin-right: 4px;
@@ -246,34 +285,38 @@
}
.cm-selectionBackground {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
.cm-activeLine > span {
font-size: 12px !important;
}
.cm-placeholder {
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
}
}
}
.close-btn {
position: absolute;
top: 0;
right: 0;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
height: 38px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
height: 100%;
width: 38px;
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
&:focus:not(:focus-visible),
&.ant-btn:focus:not(:focus-visible) {
border-color: var(--l2-border);
border-left-color: transparent;
outline: none;
box-shadow: none;
}
}
}
}
@@ -300,20 +343,8 @@
font-size: 12px !important;
}
$add-on-row-height: 38px;
.periscope-input-with-label {
.input {
.ant-select {
height: $add-on-row-height;
}
}
}
.input-with-label {
.input {
height: $add-on-row-height;
}
input {
min-height: 36px;
}
}
}

View File

@@ -23,7 +23,7 @@
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--l2-foreground) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
&.error {
.cm-editor {
@@ -51,14 +51,15 @@
.cm-content {
border-radius: 2px;
border: 1px solid var(--l1-border);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
padding: 0px !important;
background-color: var(--l1-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&:focus-within {
border-color: var(--l1-border);
border-color: var(--query-builder-v2-border-color, var(--l2-border));
}
}
@@ -74,7 +75,7 @@
right: 0px !important;
border-radius: 4px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
@@ -118,7 +119,7 @@
box-sizing: border-box;
overflow: hidden;
color: var(--l2-foreground) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
font-family: 'Space Mono', monospace !important;
.cm-completionIcon {
@@ -127,7 +128,10 @@
&:hover,
&[aria-selected='true'] {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
color: var(--l1-foreground) !important;
font-weight: 600 !important;
}
@@ -142,15 +146,24 @@
.cm-line {
line-height: 36px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
::-moz-selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
@@ -159,8 +172,11 @@
}
.chip-decorator {
background: var(--l3-background) !important;
color: var(--l1-foreground) !important;
background: var(
--query-builder-v2-chip-decorator-background-color,
var(--l3-background)
) !important;
color: var(--query-builder-v2-color, var(--l1-foreground)) !important;
border-radius: 4px;
padding: 2px 4px;
margin-right: 4px;
@@ -168,7 +184,10 @@
}
.cm-selectionBackground {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
}
@@ -201,12 +220,11 @@
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
height: 38px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
height: 100%;
width: 38px;
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
@@ -217,13 +235,13 @@
height: 36px;
line-height: 36px;
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
font-family: 'Space Mono', monospace !important;
&::placeholder {
color: var(--l1-foreground);
color: var(--query-builder-v2-color, var(--l2-foreground));
opacity: 0.5;
}
}
@@ -238,9 +256,10 @@
.query-aggregation-interval-input-container {
.query-aggregation-interval-input {
input {
min-height: 36px;
max-width: 120px;
&::placeholder {
color: var(--l2-foreground);
color: var(--query-builder-v2-color, var(--l2-foreground));
}
}
}
@@ -251,8 +270,8 @@
.query-aggregation-error-popover {
.ant-popover-inner {
background-color: var(--l1-border);
border: 1px solid var(--l1-border);
background-color: var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-radius: 4px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
}

View File

@@ -1,7 +1,7 @@
.add-trace-operator-button,
.add-new-query-button,
.add-formula-button {
border: 1px solid var(--l1-border);
background: var(--l2-background);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}

View File

@@ -40,11 +40,14 @@ $max-recents-shown: 5;
.query-status-container {
width: 32px;
background-color: var(--l1-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-radius: 2px;
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;
@@ -83,16 +86,16 @@ $max-recents-shown: 5;
.cm-content {
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
padding: 0px !important;
&:focus-within {
border-color: var(--l1-border);
border-color: var(--query-builder-v2-border-color, var(--l2-border));
}
}
&.cm-focused {
outline: 1px solid var(--l1-border);
outline: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
}
.cm-tooltip-autocomplete {
@@ -183,11 +186,17 @@ $max-recents-shown: 5;
font-family: 'Space Mono', monospace !important;
background-color: var(--l1-background) !important;
color: var(--l2-foreground) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
&:hover {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
}
.cm-completionIcon {
@@ -205,7 +214,10 @@ $max-recents-shown: 5;
}
&[aria-selected='true'] {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
font-weight: 600 !important;
}
}
@@ -274,25 +286,49 @@ $max-recents-shown: 5;
}
.cm-line {
line-height: 34px !important;
line-height: 36px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
::-moz-selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
}
.cm-selectionBackground {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
.cm-placeholder {
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
}
}
.cursor-position {

View File

@@ -65,6 +65,14 @@
display: flex;
flex-direction: column;
gap: 8px;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
}
&-aggregation-container {

View File

@@ -2,7 +2,6 @@ import { Button } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface CheckboxValueRowProps {
value: string;
@@ -47,11 +46,9 @@ function CheckboxValueRow({
{customRendererForValue ? (
customRendererForValue(value)
) : (
<TooltipSimple title={value} side="top" align="center" arrow>
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
</TooltipSimple>
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{onlyButtonLabel}

View File

@@ -22,7 +22,6 @@ 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';
@@ -31,33 +30,6 @@ 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;
@@ -138,7 +110,6 @@ 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
@@ -175,57 +146,37 @@ 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'}
<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')}
>
<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>
)}
</>
{it.icon}
</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
}

View File

@@ -31,7 +31,6 @@ export enum LOCALSTORAGE {
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
FUNNEL_STEPS = 'FUNNEL_STEPS',
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',

View File

@@ -43,17 +43,10 @@ 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, authzDevTools } = deps;
const { navigate, handleThemeChange, aiAssistant } = deps;
const actions: CmdAction[] = [
{
@@ -309,17 +302,5 @@ 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

@@ -177,13 +177,7 @@ export default function ConversationView({
conversationId={conversationId}
messages={messages}
isStreaming={isStreamingHere}
onSendSuggestedPrompt={(text): void => {
handleSend(
text,
undefined,
autoContexts.length > 0 ? autoContexts : undefined,
);
}}
onSendSuggestedPrompt={(text): void => handleSend(text)}
/>
{showDisclaimer && (
<div className={disclaimerClass} role="note" aria-live="polite">

View File

@@ -1,142 +0,0 @@
import { MemoryRouter } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { fireEvent, render } from '@testing-library/react';
import { MessageContext } from 'api/ai-assistant/chat';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { VariantContext } from 'container/AIAssistant/VariantContext';
const CHIP_ID = 'recent-errors';
const CHIP_TEXT = 'Show me recent errors';
// Auto-derived page context that a normal (typed) send would attach. The chip
// send must forward the exact same array.
const mockAutoContexts: MessageContext[] = [
{
source: 'auto',
type: 'dashboard',
resourceId: 'dashboard-123',
resourceName: 'Checkout dashboard',
},
];
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('container/AIAssistant/getAutoContexts', () => ({
getAutoContexts: jest.fn(() => mockAutoContexts),
}));
jest.mock('container/AIAssistant/hooks/useAIAssistantAnalyticsContext', () => ({
normalizePage: (page: string): string => page,
useAIAssistantAnalyticsContext: (): unknown => ({ threadId: 'thread-1' }),
}));
// ChatInput is heavy and irrelevant here — the chip path lives entirely in the
// empty state. Provide a lightweight stub plus the `autoContextKey` named export
// ConversationView imports for its dismissed-context filter.
jest.mock('container/AIAssistant/components/ChatInput', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="chat-input" />,
autoContextKey: (): string => '',
}));
jest.mock('container/AIAssistant/components/ConversationSkeleton', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="skeleton" />,
}));
// VirtualizedMessages renders the real empty-state chips; stub only its
// never-rendered-in-empty-state children and the virtual list.
jest.mock('components/Noz/Noz', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="noz" />,
}));
jest.mock('container/AIAssistant/components/MessageBubble', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('container/AIAssistant/components/StreamingMessage', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('react-virtuoso', () => ({
__esModule: true,
Virtuoso: (): null => null,
}));
jest.mock(
'container/AIAssistant/components/VirtualizedMessages/useEmptyStateChips',
() => ({
useEmptyStateChips: (): { chips: { id: string; text: string }[] } => ({
chips: [{ id: CHIP_ID, text: CHIP_TEXT }],
}),
}),
);
// eslint-disable-next-line import/first
import ConversationView from '../ConversationView';
const CONVERSATION_ID = 'conv-1';
function renderView(variant: 'panel' | 'page' | 'modal'): {
getByTestId: (id: string) => HTMLElement;
} {
return render(
<MemoryRouter initialEntries={['/dashboard/dashboard-123']}>
<VariantContext.Provider value={variant}>
<ConversationView conversationId={CONVERSATION_ID} />
</VariantContext.Provider>
</MemoryRouter>,
);
}
describe('ConversationView — empty-state chip context', () => {
let sendMessage: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
sendMessage = jest.fn();
useAIAssistantStore.setState({
conversations: {
[CONVERSATION_ID]: {
id: CONVERSATION_ID,
messages: [],
createdAt: 1,
updatedAt: 1,
},
},
streams: {},
activeConversationId: CONVERSATION_ID,
isLoadingThread: false,
sendMessage,
} as unknown as Partial<ReturnType<typeof useAIAssistantStore.getState>>);
});
it('forwards the page auto-contexts when a chip is clicked (embedded variant)', () => {
const { getByTestId } = renderView('panel');
fireEvent.click(getByTestId(`empty-state-chip-${CHIP_ID}`));
// The chip send must carry the same auto-contexts a typed message would.
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
CHIP_TEXT,
undefined,
mockAutoContexts,
);
});
it('sends undefined contexts on the standalone page (no page context to attach)', () => {
const { getByTestId } = renderView('page');
fireEvent.click(getByTestId(`empty-state-chip-${CHIP_ID}`));
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(CHIP_TEXT, undefined, undefined);
});
});

View File

@@ -149,18 +149,6 @@
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
padding: 20px 16px 0px 16px;
/* Preserve author-entered line breaks in the description. */
white-space: pre-wrap;
overflow-wrap: anywhere;
a {
color: var(--accent-primary);
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
.dashboard-variables {

View File

@@ -43,7 +43,6 @@ import { sortLayout } from 'providers/Dashboard/util';
import { DashboardData } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
import { ROLES, USER_ROLES } from 'types/roles';
import { linkifyText } from 'utils/linkifyText';
import { ComponentTypes } from 'utils/permission';
import { v4 as uuid } from 'uuid';
@@ -516,9 +515,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
</div>
)}
{!isEmpty(description) && (
<section className="dashboard-description-section">
{linkifyText(description ?? '')}
</section>
<section className="dashboard-description-section">{description}</section>
)}
{!isEmpty(dashboardVariables) && (

View File

@@ -53,6 +53,26 @@
}
.alert-query-section-container {
--query-builder-v2-color: var(--l2-foreground);
--query-builder-v2-background-color: var(--l3-background);
--query-builder-v2-border-color: var(--l3-border);
--query-builder-v2-selection-background-color: var(--l2-background);
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
--query-builder-v2-placeholder-color: var(--l3-foreground);
--query-builder-v2-selected-background-color: var(--l3-foreground);
--query-search-background-color: var(--l3-background);
--query-search-background-color-selection: var(--l3-background);
--input-with-label-border-color: var(--l3-border);
--input-with-label-background-color: var(--l3-background);
--input-with-label-color: var(--l2-foreground);
--query-builder-v2-toggle-group-background-color: var(--l3-background);
--query-builder-v2-toggle-group-border-color: var(--l3-border);
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
--input-height: 36px;
--input-hover-background: var(--l3-background);
--input-hover-border-color: var(--internal-ant-border-color-hover);
.ant-card-body {
padding: 0px;

View File

@@ -3,6 +3,30 @@
overflow-x: auto;
overflow-y: hidden;
--query-builder-v2-color: var(--l2-foreground);
--query-builder-v2-background-color: var(--l3-background);
--query-builder-v2-border-color: var(--l3-border);
--query-builder-v2-selection-background-color: var(--l2-background);
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
--query-builder-v2-placeholder-color: var(--l3-foreground);
--query-builder-v2-selected-background-color: var(--l3-foreground);
--query-search-background-color: var(--l3-background);
--query-search-background-color-selection: var(--l3-background);
--input-with-label-border-color: var(--l3-border);
--input-with-label-background-color: var(--l3-background);
--input-with-label-color: var(--l2-foreground);
--query-builder-v2-toggle-group-background-color: var(--l3-background);
--query-builder-v2-toggle-group-border-color: var(--l3-border);
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
--input-height: 36px;
--input-hover-background: var(--l3-background);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
--input-background: var(--l3-background);
--input-focus-background: var(--l3-background);
--input-border-color: var(--l3-border);
.full-view-header-container {
display: flex;
flex-direction: column;

View File

@@ -1625,9 +1625,6 @@ export const getHostQueryPayload = (
const diskPendingKey = dotMetricsEnabled
? 'system.disk.pending_operations'
: 'system_disk_pending_operations';
const fsUsageKey = dotMetricsEnabled
? 'system.filesystem.usage'
: 'system_filesystem_usage';
return [
{
@@ -2660,155 +2657,6 @@ export const getHostQueryPayload = (
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_filesystem_usage--float64--Gauge--true',
key: fsUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: true,
expression: 'A',
filters: {
items: [
{
id: 'fs_f1',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
key: hostNameKey,
type: 'tag',
},
op: '=',
value: hostName,
},
{
id: 'fs_f2',
key: {
dataType: DataTypes.String,
id: 'state--string--tag--false',
key: 'state',
type: 'tag',
},
op: '=',
value: 'used',
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'mountpoint--string--tag--false',
key: 'mountpoint',
type: 'tag',
},
],
having: [
{
columnName: `SUM(${fsUsageKey})`,
op: '>',
value: 0,
},
],
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_filesystem_usage--float64--Gauge--true',
key: fsUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: true,
expression: 'B',
filters: {
items: [
{
id: 'fs_f3',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
key: hostNameKey,
type: 'tag',
},
op: '=',
value: hostName,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'mountpoint--string--tag--false',
key: 'mountpoint',
type: 'tag',
},
],
having: [
{
columnName: `SUM(${fsUsageKey})`,
op: '>',
value: 0,
},
],
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [
{
disabled: false,
expression: 'A/B',
legend: '{{mountpoint}}',
queryName: 'F1',
},
],
queryTraceOperator: [],
},
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
];
};
@@ -2884,5 +2732,4 @@ export const hostWidgetInfo = [
{ title: 'System disk operations/s', yAxisUnit: 'short' },
{ title: 'Queue size', yAxisUnit: 'short' },
{ title: 'System disk operation time/s', yAxisUnit: 's' },
{ title: 'Disk Usage (%) by mountpoint', yAxisUnit: 'percentunit' },
];

View File

@@ -33,8 +33,7 @@
color: var(--foreground);
letter-spacing: -0.07px;
line-height: var(--paragraph-base-400-line-height);
margin-bottom: var(--spacing-8);
margin-top: 0;
margin: 0;
}
&__controls {

View File

@@ -14,6 +14,14 @@
.meter-explorer-content-section {
width: 100%;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.explore-header {
display: flex;
align-items: center;

View File

@@ -1,6 +1,14 @@
.metrics-explorer-explore-container {
padding-bottom: 80px;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.explore-header {
display: flex;
align-items: center;

View File

@@ -1,4 +1,12 @@
.dashboard-navigation {
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.run-query-dashboard-btn {
min-width: 180px;
}

View File

@@ -20,7 +20,6 @@ import azureOpenaiUrl from '@/assets/Logos/azure-openai.svg';
import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metrics.svg';
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
import basetenUrl from '@/assets/Logos/baseten.svg';
import cassandraUrl from '@/assets/Logos/cassandra.svg';
import celeryUrl from '@/assets/Logos/celery.svg';
import certManagerUrl from '@/assets/Logos/cert-manager.svg';
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
@@ -52,7 +51,6 @@ import externalApiMonitoringUrl from '@/assets/Logos/external-api-monitoring.svg
import fluentbitUrl from '@/assets/Logos/fluentbit.svg';
import fluentdUrl from '@/assets/Logos/fluentd.svg';
import flutterMonitoringUrl from '@/assets/Logos/flutter-monitoring.svg';
import fluxcdUrl from '@/assets/Logos/fluxcd.svg';
import flyIoUrl from '@/assets/Logos/fly-io.svg';
import fromLogFileUrl from '@/assets/Logos/from-log-file.svg';
import gcpAppEngineUrl from '@/assets/Logos/gcp-app-engine.svg';
@@ -96,7 +94,6 @@ import langtraceUrl from '@/assets/Logos/langtrace.svg';
import litellmUrl from '@/assets/Logos/litellm.svg';
import livekitUrl from '@/assets/Logos/livekit.svg';
import llamaindexUrl from '@/assets/Logos/llamaindex.svg';
import llmMonitoringUrl from '@/assets/Logos/llm-monitoring.svg';
import logrusUrl from '@/assets/Logos/logrus.svg';
import logsUrl from '@/assets/Logos/logs.svg';
import logstashUrl from '@/assets/Logos/logstash.svg';
@@ -123,7 +120,6 @@ import opentelemetryUrl from '@/assets/Logos/opentelemetry.svg';
import phpUrl from '@/assets/Logos/php.svg';
import pinoUrl from '@/assets/Logos/pino.svg';
import pipecatUrl from '@/assets/Logos/pipecat.svg';
import planetscaleUrl from '@/assets/Logos/planetscale.svg';
import postgresqlUrl from '@/assets/Logos/postgresql.svg';
import prometheusUrl from '@/assets/Logos/prometheus.svg';
import pydanticAiUrl from '@/assets/Logos/pydantic-ai.svg';
@@ -1530,28 +1526,6 @@ const onboardingConfigWithLinks = [
id: 'nginx-tracing',
link: '/docs/instrumentation/opentelemetry-nginx/',
},
{
dataSource: 'nginx-ingress-controller',
label: 'NGINX Ingress Controller',
imgUrl: nginxUrl,
tags: ['infrastructure monitoring'],
module: 'metrics',
relatedSearchKeywords: [
'ingress',
'ingress controller',
'kubernetes ingress',
'monitoring',
'nginx ingress',
'nginx ingress controller',
'nginx ingress metrics',
'nginx ingress monitoring',
'nginx ingress observability',
'observability',
'opentelemetry nginx ingress',
],
id: 'nginx-ingress-controller',
link: '/docs/metrics-management/nginx-ingress-controller/',
},
{
dataSource: 'opentelemetry-cloudflare',
label: 'Cloudflare Tracing',
@@ -1612,119 +1586,6 @@ const onboardingConfigWithLinks = [
id: 'opentelemetry-cloudflare-logs',
link: '/docs/logs-management/send-logs/cloudflare-logs/',
},
{
dataSource: 'cloudflare-workers',
label: 'Cloudflare Workers',
imgUrl: cloudflareUrl,
tags: ['apm/traces'],
module: 'apm',
relatedSearchKeywords: [
'cloudflare',
'cloudflare workers',
'cloudflare workers monitoring',
'cloudflare workers observability',
'cloudflare workers otlp',
'edge computing monitoring',
'monitor cloudflare workers',
'monitoring',
'observability',
'opentelemetry cloudflare workers',
'otlp',
'serverless monitoring',
],
id: 'cloudflare-workers',
link: '/docs/integrations/outposts/cloudflare-workers/',
},
{
dataSource: 'opentelemetry-cassandra',
label: 'Cassandra',
imgUrl: cassandraUrl,
tags: ['database'],
module: 'apm',
relatedSearchKeywords: [
'apache cassandra',
'cassandra',
'cassandra database',
'cassandra logs',
'cassandra metrics',
'cassandra monitoring',
'cassandra observability',
'database',
'monitoring',
'nosql',
'observability',
'opentelemetry cassandra',
],
id: 'opentelemetry-cassandra',
link: '/docs/integrations/opentelemetry-cassandra/',
},
{
dataSource: 'fluxcd',
label: 'FluxCD',
imgUrl: fluxcdUrl,
tags: ['infrastructure monitoring'],
module: 'metrics',
relatedSearchKeywords: [
'continuous delivery',
'flux',
'fluxcd',
'fluxcd dashboard',
'fluxcd metrics',
'fluxcd monitoring',
'fluxcd observability',
'gitops',
'kubernetes',
'monitoring',
'observability',
'opentelemetry fluxcd',
],
id: 'fluxcd',
link: '/docs/metrics-management/fluxcd-metrics/',
},
{
dataSource: 'planetscale',
label: 'PlanetScale',
imgUrl: planetscaleUrl,
tags: ['database'],
module: 'apm',
relatedSearchKeywords: [
'database',
'monitoring',
'mysql',
'observability',
'opentelemetry planetscale',
'planetscale',
'planetscale database',
'planetscale metrics',
'planetscale monitoring',
'planetscale observability',
'serverless database',
],
id: 'planetscale',
link: '/docs/metrics-management/opentelemetry-planetscale/',
},
{
dataSource: 'hermes-agent',
label: 'Hermes Agent',
imgUrl: llmMonitoringUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'ai agent monitoring',
'hermes',
'hermes agent',
'hermes agent monitoring',
'hermes agent observability',
'hermes monitoring',
'llm monitoring',
'monitoring',
'nous research',
'observability',
'opentelemetry hermes',
],
id: 'hermes-agent',
link: '/docs/hermes-monitoring/',
},
{
dataSource: 'convex-logs',
label: 'Convex Logs',

View File

@@ -31,8 +31,14 @@
min-width: 32px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
--periscope-btn-border-color: var(
--query-builder-v2-border-color,
var(--l2-border)
);
--periscope-btn-background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}

View File

@@ -27,8 +27,11 @@
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;
background-color: var(--l1-border) !important;
opacity: 0.8;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
margin-left: -1px;
&:disabled {
opacity: 0.4;
@@ -86,8 +89,8 @@
border-bottom-left-radius: 3px;
.ant-select-selector {
border: none;
background: var(--l3-background);
border: 1px solid var(--query-builder-v2-border-color, none);
background: var(--query-builder-v2-background-color, var(--l3-background));
}
&.showInput {
@@ -101,12 +104,20 @@
.query-function-value {
width: 70px;
border-left: 0;
background: var(--l2-background);
background: var(--query-builder-v2-background-color, var(--l2-background));
border-radius: 0;
border: 1px solid transparent;
border-top: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-bottom: 1px solid
var(--query-builder-v2-border-color, var(--l2-border));
height: 32px;
&:focus {
border-color: transparent !important;
border-color: var(--internal-ant-border-color-focus);
}
&:hover {
border-color: var(--internal-ant-border-color-hover);
}
}
@@ -114,7 +125,7 @@
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
border-left: 1px solid var(--l2-border);
border-left: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;

View File

@@ -7,48 +7,19 @@ 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 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import ROUTES from 'constants/routes';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import useUrlQuery from 'hooks/useUrlQuery';
import { useNavigationBlocker } from 'hooks/useNavigationBlocker';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { RouterContext } from 'lib/authz/components/withAuthZ/withAuthZ';
import {
buildRoleReadPermission,
buildRoleUpdatePermission,
RoleCreatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import APIError from 'types/api/error';
import PermissionEditor from './components/PermissionEditor';
import { useCreateEditRolePageActions } from './useCreateEditRolePageActions';
import { useNavigationBlocker } from 'hooks/useNavigationBlocker';
import styles from './CreateEditRolePage.module.scss';
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
function authzCheckFn(
_props: object,
router: RouterContext,
): BrandedPermission[] {
const match = router.matchPath<{ roleId: string }>(ROUTES.ROLE_DETAILS);
const roleId = match?.roleId ?? 'new';
const roleName = router.searchParams.get('name') ?? '';
const isCreateMode = roleId === 'new';
if (isCreateMode) {
return [RoleCreatePermission];
}
if (roleName) {
return [
buildRoleReadPermission(roleName),
buildRoleUpdatePermission(roleName),
];
}
return [];
}
function CreateEditRolePageContent(): JSX.Element {
function CreateEditRolePage(): JSX.Element {
const history = useHistory();
const { pathname } = useLocation();
const urlQuery = useUrlQuery();
@@ -76,6 +47,9 @@ function CreateEditRolePageContent(): JSX.Element {
saveError,
validationErrors,
isCreateMode,
hasRequiredPermission,
isAuthZLoading,
deniedPermission,
loadError,
} = useCreateEditRolePageActions(roleId, roleName);
@@ -107,6 +81,10 @@ function CreateEditRolePageContent(): JSX.Element {
roleName,
]);
if (!hasRequiredPermission && !isAuthZLoading) {
return <PermissionDeniedFullPage permissionName={deniedPermission} />;
}
if (!isRolesEnabled && !isFeatureGateLoading) {
return (
<div
@@ -149,7 +127,7 @@ function CreateEditRolePageContent(): JSX.Element {
);
}
if ((isLoading && !isCreateMode) || isFeatureGateLoading) {
if (isAuthZLoading || (isLoading && !isCreateMode) || isFeatureGateLoading) {
return (
<div className={styles.createEditRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
@@ -217,12 +195,7 @@ function CreateEditRolePageContent(): JSX.Element {
</Typography>
</div>
)}
<AuthZButton
checks={
isCreateMode
? [RoleCreatePermission]
: [buildRoleUpdatePermission(roleName)]
}
<Button
variant="solid"
color="primary"
onClick={handleSaveAndNavigate}
@@ -231,7 +204,7 @@ function CreateEditRolePageContent(): JSX.Element {
data-testid="save-button"
>
{isCreateMode ? 'Create role' : 'Save changes'}
</AuthZButton>
</Button>
</div>
</div>
@@ -317,11 +290,4 @@ function CreateEditRolePageContent(): JSX.Element {
);
}
export default withAuthZPage(CreateEditRolePageContent, {
checks: authzCheckFn,
fallbackOnLoading: (
<div className={styles.createEditRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
),
});
export default CreateEditRolePage;

View File

@@ -1,21 +1,24 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { FeatureKeys } from 'constants/features';
import { server } from 'mocks-server/server';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
import {
invalidLicense,
setupAuthzAdmin,
mockUseAuthZGrantAll,
} from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
beforeEach(() => {
server.use(setupAuthzAdmin());
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
function renderCreatePage(
@@ -68,9 +71,7 @@ describe('CreateEditRolePage - Feature Gate', () => {
),
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
@@ -79,9 +80,7 @@ describe('CreateEditRolePage - Feature Gate', () => {
it('shows error when license is invalid', async () => {
renderCreatePage({ activeLicense: invalidLicense });
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
@@ -93,19 +92,16 @@ describe('CreateEditRolePage - Feature Gate', () => {
await expect(screen.findByText('Create Role')).resolves.toBeInTheDocument();
});
it('shows back button when feature disabled', async () => {
it('shows back button when feature disabled', () => {
renderCreatePage({ activeLicense: invalidLicense });
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('back button is enabled when feature disabled', async () => {
it('back button is enabled when feature disabled', () => {
renderCreatePage({ activeLicense: invalidLicense });
const cancelButton = await screen.findByTestId('cancel-button');
expect(cancelButton).not.toBeDisabled();
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
});
});
@@ -122,9 +118,7 @@ describe('CreateEditRolePage - Feature Gate', () => {
),
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
@@ -133,9 +127,7 @@ describe('CreateEditRolePage - Feature Gate', () => {
it('shows error when license is invalid', async () => {
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();

View File

@@ -1,17 +1,16 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { render, screen } from 'tests/test-utils';
import {
setupAuthzAdmin,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import { mockUseAuthZDenyAll } from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
function renderCreatePage(): ReturnType<typeof render> {
@@ -32,7 +31,7 @@ function renderCreatePage(): ReturnType<typeof render> {
describe('CreateRolePage - AuthZ', () => {
describe('permission denied', () => {
it('shows PermissionDeniedFullPage when create permission denied', async () => {
server.use(setupAuthzDenyAll());
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
renderCreatePage();
@@ -44,31 +43,17 @@ describe('CreateRolePage - AuthZ', () => {
describe('loading state', () => {
it('shows skeleton while checking permissions', () => {
server.use(
rest.post('*/api/v1/authz/check', (_req, res, ctx) =>
res(
ctx.delay(200),
ctx.status(200),
ctx.json({ data: [], status: 'success' }),
),
),
);
mockUseAuthZ.mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
refetchPermissions: jest.fn(),
});
renderCreatePage();
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
describe('permission granted', () => {
it('renders create page when create permission granted', async () => {
server.use(setupAuthzAdmin());
renderCreatePage();
await expect(
screen.findByTestId('role-name-input'),
).resolves.toBeInTheDocument();
});
});
});

View File

@@ -3,22 +3,27 @@ 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 { setupAuthzAdmin } from 'lib/authz/utils/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('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const rolesApiBase = '*/api/v1/roles';
beforeEach(() => {
server.use(setupAuthzAdmin());
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
async function renderCreatePage(): Promise<ReturnType<typeof render>> {
const result = render(
function renderCreatePage(): ReturnType<typeof render> {
return render(
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
@@ -30,63 +35,61 @@ async function renderCreatePage(): Promise<ReturnType<typeof render>> {
undefined,
{ initialRoute: '/settings/roles/new' },
);
await screen.findByTestId('create-edit-role-page');
return result;
}
describe('CreateRolePage', () => {
describe('initial render', () => {
it('renders create role page with testId', async () => {
await renderCreatePage();
it('renders create role page with testId', () => {
renderCreatePage();
expect(screen.getByTestId('create-edit-role-page')).toBeInTheDocument();
});
it('shows breadcrumb with "Create role" as current page', async () => {
await renderCreatePage();
it('shows breadcrumb with "Create role" as current page', () => {
renderCreatePage();
const page = screen.getByTestId('create-edit-role-page');
const breadcrumbs = within(page).getAllByText('Create role');
expect(breadcrumbs.length).toBeGreaterThanOrEqual(1);
});
it('renders empty name input', async () => {
await renderCreatePage();
it('renders empty name input', () => {
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
expect(nameInput).toHaveValue('');
});
it('renders empty description input', async () => {
await renderCreatePage();
it('renders empty description input', () => {
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
expect(descInput).toHaveValue('');
});
it('name input is enabled in create mode', async () => {
await renderCreatePage();
it('name input is enabled in create mode', () => {
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
expect(nameInput).not.toBeDisabled();
});
it('save button shows "Create role" text', async () => {
await renderCreatePage();
it('save button shows "Create role" text', () => {
renderCreatePage();
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).toHaveTextContent('Create role');
});
it('save button is disabled when no changes', async () => {
await renderCreatePage();
it('save button is disabled when no changes', () => {
renderCreatePage();
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).toBeDisabled();
});
it('does not show unsaved indicator initially', async () => {
await renderCreatePage();
it('does not show unsaved indicator initially', () => {
renderCreatePage();
expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument();
});
@@ -95,7 +98,7 @@ describe('CreateRolePage', () => {
describe('form interactions', () => {
it('enables save button when name is entered', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'test-role');
@@ -106,7 +109,7 @@ describe('CreateRolePage', () => {
it('shows unsaved indicator when form modified', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'my-role');
@@ -118,7 +121,7 @@ describe('CreateRolePage', () => {
it('enables save button when description is entered', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Some description');
@@ -131,7 +134,7 @@ describe('CreateRolePage', () => {
describe('cancel action', () => {
it('navigates to roles list on cancel', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const cancelBtn = screen.getByTestId('cancel-button');
await user.click(cancelBtn);
@@ -160,7 +163,7 @@ describe('CreateRolePage', () => {
);
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'my-custom-role');
@@ -197,7 +200,7 @@ describe('CreateRolePage', () => {
);
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Description only');
@@ -215,7 +218,7 @@ describe('CreateRolePage', () => {
it('shows error banner with "Role name is required" when saving with empty name', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Description only');
@@ -234,7 +237,7 @@ describe('CreateRolePage', () => {
it('clears error banner when user starts typing in name field', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Description only');
@@ -267,7 +270,7 @@ describe('CreateRolePage', () => {
);
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'duplicate-role');
@@ -288,7 +291,7 @@ describe('CreateRolePage', () => {
describe('validation errors', () => {
it('shows validation error when Only Selected has no items', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');

View File

@@ -1,43 +1,22 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { render, screen } from 'tests/test-utils';
import {
setupAuthzAdmin,
setupAuthzDenyAll,
setupAuthzDeny,
mockUseAuthZDenyAll,
mockUseAuthZGrantByPrefix,
} from 'lib/authz/utils/authz-test-utils';
import { buildRoleUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const EDIT_ROLE_ID = 'test-role-123';
const EDIT_ROLE_NAME = 'test-role';
const rolesApiBase = '*/api/v1/roles';
beforeEach(() => {
server.use(
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
id: EDIT_ROLE_ID,
name: EDIT_ROLE_NAME,
description: 'Test role description',
type: 'custom',
transactionGroups: [],
},
}),
),
),
);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
function renderEditPage(): ReturnType<typeof render> {
@@ -58,7 +37,7 @@ function renderEditPage(): ReturnType<typeof render> {
describe('EditRolePage - AuthZ', () => {
describe('permission denied', () => {
it('shows PermissionDeniedFullPage when read permission denied', async () => {
server.use(setupAuthzDenyAll());
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
renderEditPage();
@@ -68,7 +47,7 @@ describe('EditRolePage - AuthZ', () => {
});
it('shows PermissionDeniedFullPage when update permission denied but read granted', async () => {
server.use(setupAuthzDeny(buildRoleUpdatePermission(EDIT_ROLE_NAME)));
mockUseAuthZ.mockImplementation(mockUseAuthZGrantByPrefix('read'));
renderEditPage();
@@ -76,35 +55,34 @@ describe('EditRolePage - AuthZ', () => {
screen.findByText(/You are not authorized/i),
).resolves.toBeInTheDocument();
});
it('checks both read and update permissions for edit mode', () => {
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
renderEditPage();
expect(mockUseAuthZ).toHaveBeenCalledWith(
expect.arrayContaining([
expect.stringContaining('read'),
expect.stringContaining('update'),
]),
);
});
});
describe('loading state', () => {
it('shows skeleton while checking permissions', async () => {
server.use(
rest.post('*/api/v1/authz/check', (_req, res, ctx) =>
res(
ctx.delay(200),
ctx.status(200),
ctx.json({ data: [], status: 'success' }),
),
),
);
it('shows skeleton while checking permissions', () => {
mockUseAuthZ.mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
refetchPermissions: jest.fn(),
});
renderEditPage();
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
describe('permission granted', () => {
it('renders edit page when both read and update permissions granted', async () => {
server.use(setupAuthzAdmin());
renderEditPage();
await expect(
screen.findByText(`Role - ${EDIT_ROLE_NAME}`),
).resolves.toBeInTheDocument();
});
});
});

View File

@@ -4,10 +4,14 @@ 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 { setupAuthzAdmin } from 'lib/authz/utils/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('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
const rolesApiBase = '*/api/v1/roles';
@@ -28,8 +32,8 @@ const roleWithTransactionGroups = {
};
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
server.use(
setupAuthzAdmin(),
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(roleWithTransactionGroups)),
),
@@ -37,6 +41,7 @@ beforeEach(() => {
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
@@ -111,9 +116,9 @@ describe('EditRolePage', () => {
renderEditPage();
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
});
it('navigates to view page when cancel clicked in error state', async () => {
@@ -127,8 +132,10 @@ describe('EditRolePage', () => {
renderEditPage();
const cancelButton = await screen.findByTestId('cancel-button');
await user.click(cancelButton);
await waitFor(async () => {
const cancelButton = await screen.findByTestId('cancel-button');
await user.click(cancelButton);
});
await expect(
screen.findByTestId('role-details-redirect'),

View File

@@ -1,18 +1,21 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { render, screen, userEvent, within } from 'tests/test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/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('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
beforeEach(() => {
server.use(setupAuthzAdmin());
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
function renderPage(): ReturnType<typeof render> {
@@ -34,13 +37,13 @@ function renderPage(): ReturnType<typeof render> {
async function switchToJsonMode(): Promise<void> {
const user = userEvent.setup();
const jsonRadio = await screen.findByTestId('permission-editor-mode-json');
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
}
async function switchToInteractiveMode(): Promise<void> {
const user = userEvent.setup();
const interactiveRadio = await screen.findByTestId(
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
);
await user.click(interactiveRadio);

View File

@@ -1,28 +1,31 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/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('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
async function expandAllCards(): Promise<void> {
const user = userEvent.setup();
const expandButton = await screen.findByTestId('expand-all-button');
const expandButton = screen.getByTestId('expand-all-button');
await user.click(expandButton);
}
beforeEach(() => {
server.use(setupAuthzAdmin());
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
async function renderPage(): Promise<ReturnType<typeof render>> {
const result = render(
function renderPage(): ReturnType<typeof render> {
return render(
<TooltipProvider>
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
@@ -36,20 +39,18 @@ async function renderPage(): Promise<ReturnType<typeof render>> {
undefined,
{ initialRoute: '/settings/roles/new' },
);
await screen.findByTestId('permission-editor');
return result;
}
describe('PermissionEditor', () => {
describe('mode toggle', () => {
it('renders permission editor with testId', async () => {
await renderPage();
it('renders permission editor with testId', () => {
renderPage();
expect(screen.getByTestId('permission-editor')).toBeInTheDocument();
});
it('defaults to interactive mode', async () => {
await renderPage();
it('defaults to interactive mode', () => {
renderPage();
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
@@ -59,7 +60,7 @@ describe('PermissionEditor', () => {
it('switches to JSON mode when clicked', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
@@ -70,7 +71,7 @@ describe('PermissionEditor', () => {
it('switches back to interactive mode', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
@@ -86,8 +87,8 @@ describe('PermissionEditor', () => {
});
describe('resource cards', () => {
it('renders all resource cards', async () => {
await renderPage();
it('renders all resource cards', () => {
renderPage();
expect(
screen.getByTestId('resource-card-factor-api-key'),
@@ -98,8 +99,8 @@ describe('PermissionEditor', () => {
).toBeInTheDocument();
});
it('resource cards are collapsed by default', async () => {
await renderPage();
it('resource cards are collapsed by default', () => {
renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -111,7 +112,7 @@ describe('PermissionEditor', () => {
it('expands resource card when header clicked', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -125,7 +126,7 @@ describe('PermissionEditor', () => {
it('collapses expanded resource card when header clicked again', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -139,7 +140,7 @@ describe('PermissionEditor', () => {
});
it('shows granted count in resource card header', async () => {
await renderPage();
renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
await expect(
@@ -150,7 +151,7 @@ describe('PermissionEditor', () => {
describe('action toggles', () => {
it('renders action toggles for each available action', async () => {
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -169,7 +170,7 @@ describe('PermissionEditor', () => {
});
it('defaults all actions to None scope', async () => {
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -187,7 +188,7 @@ describe('PermissionEditor', () => {
it('changes scope to All when clicked', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -208,7 +209,7 @@ describe('PermissionEditor', () => {
it('updates granted count when scope changed', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -227,7 +228,7 @@ describe('PermissionEditor', () => {
describe('Only Selected scope', () => {
it('shows item input selector when Only Selected is chosen', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -244,7 +245,7 @@ describe('PermissionEditor', () => {
it('adds item when typed and Enter pressed', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -262,7 +263,7 @@ describe('PermissionEditor', () => {
it('adds item when Add button clicked', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -283,7 +284,7 @@ describe('PermissionEditor', () => {
it('adds multiple items separated by comma', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -303,7 +304,7 @@ describe('PermissionEditor', () => {
it('adds multiple items separated by space', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -323,7 +324,7 @@ describe('PermissionEditor', () => {
it('does not add duplicate items', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -343,7 +344,7 @@ describe('PermissionEditor', () => {
it('removes item when X clicked', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -366,7 +367,7 @@ describe('PermissionEditor', () => {
it('shows Add button disabled when input is empty', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -384,7 +385,7 @@ describe('PermissionEditor', () => {
describe('scope change confirmation dialog', () => {
it('shows confirm dialog when leaving Only Selected with items', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -406,7 +407,7 @@ describe('PermissionEditor', () => {
it('clears items when confirmed', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -433,7 +434,7 @@ describe('PermissionEditor', () => {
it('keeps items when cancelled', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -460,7 +461,7 @@ describe('PermissionEditor', () => {
it('does not show dialog when leaving Only Selected with no items', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -479,7 +480,7 @@ describe('PermissionEditor', () => {
describe('verbs without Only Selected option', () => {
it('does not show Only Selected for list verb', async () => {
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -500,8 +501,8 @@ describe('PermissionEditor', () => {
});
describe('collapse/expand all resources', () => {
it('shows expand/collapse toggle group', async () => {
await renderPage();
it('shows expand/collapse toggle group', () => {
renderPage();
expect(screen.getByTestId('toggle-all-group')).toBeInTheDocument();
expect(screen.getByTestId('expand-all-button')).toBeInTheDocument();
@@ -509,7 +510,7 @@ describe('PermissionEditor', () => {
});
it('expands all cards when expand button clicked', async () => {
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -523,7 +524,7 @@ describe('PermissionEditor', () => {
describe('resource card error states', () => {
it('shows error border on collapsed card with validation error', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
@@ -553,7 +554,7 @@ describe('PermissionEditor', () => {
it('hides error border when card is expanded', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
@@ -590,7 +591,7 @@ describe('PermissionEditor', () => {
it('clears validation error when permission is changed', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');

View File

@@ -16,6 +16,7 @@ import {
useRolePermissions,
useUpdateRolePermissions,
} from '../hooks/useRolePermissions';
import { useRoleAuthZ } from '../hooks/useRoleAuthZ';
import {
useRoleUnsavedChanges,
type RoleFormData,
@@ -42,6 +43,9 @@ interface UseCreateEditRolePageCallbacksResult {
saveError: APIError | null;
clearSaveError: () => void;
validationErrors: Set<string>;
hasRequiredPermission: boolean;
isAuthZLoading: boolean;
deniedPermission: string;
}
export function useCreateEditRolePageActions(
@@ -51,6 +55,23 @@ export function useCreateEditRolePageActions(
const history = useHistory();
const isCreateMode = roleId === 'new';
const {
hasCreatePermission,
hasReadPermission,
hasUpdatePermission,
isAuthZLoading,
} = useRoleAuthZ(roleName);
const deniedPermission = useMemo(() => {
if (isCreateMode) {
return 'role:create';
}
if (roleName) {
return `role:${roleName}:update`;
}
return `role:<missing-rule-name>:update`;
}, [isCreateMode, roleName]);
const [formData, setFormData] = useState<RoleFormData>({
name: '',
description: '',
@@ -240,5 +261,10 @@ export function useCreateEditRolePageActions(
saveError,
clearSaveError,
validationErrors,
hasRequiredPermission: isCreateMode
? hasCreatePermission
: hasReadPermission && hasUpdatePermission,
isAuthZLoading,
deniedPermission,
};
}

View File

@@ -13,6 +13,7 @@ import APIError from 'types/api/error';
interface UseDeleteRoleModalProps {
roleId?: string | null;
isManaged: boolean;
hasDeletePermission: boolean;
onDeleteSuccess?: () => void;
}
@@ -30,7 +31,7 @@ interface UseDeleteRoleModalResult {
export function useDeleteRoleModal(
props: UseDeleteRoleModalProps,
): UseDeleteRoleModalResult {
const { roleId, isManaged, onDeleteSuccess } = props;
const { roleId, isManaged, hasDeletePermission, onDeleteSuccess } = props;
const queryClient = useQueryClient();
const [deleteTargetRoleId, setDeleteTargetRoleId] = useState<string | null>(
@@ -78,8 +79,10 @@ export function useDeleteRoleModal(
const isDeleteModalOpen = deleteTargetRoleId !== null;
const isDeleteDisabled = isManaged;
const deleteDisabledReason = 'Managed roles cannot be deleted';
const isDeleteDisabled = isManaged || !hasDeletePermission;
const deleteDisabledReason = isManaged
? 'Managed roles cannot be deleted'
: 'You do not have permission to delete this role';
return {
isDeleteModalOpen,

View File

@@ -5,11 +5,12 @@ 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 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import ROUTES from 'constants/routes';
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 { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useTimezone } from 'providers/Timezone';
import { RoleType } from 'types/roles';
@@ -23,14 +24,23 @@ type DisplayItem =
| { type: 'section'; label: string; count?: number }
| { type: 'role'; role: AuthtypesRoleDTO };
interface RolesListContentProps {
interface RolesListingTableProps {
searchQuery: string;
}
function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
function RolesListingTable({
searchQuery,
}: RolesListingTableProps): JSX.Element {
const { isRolesEnabled } = useRolesFeatureGate();
const { data, isLoading, isError, error } = useListRoles();
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
RoleListPermission,
]);
const hasListPermission = listPerms?.[RoleListPermission]?.isGranted ?? false;
const { data, isLoading, isError, error } = useListRoles({
query: { enabled: hasListPermission },
});
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
const history = useHistory();
const urlQuery = useUrlQuery();
@@ -145,7 +155,11 @@ function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
</>
);
if (isLoading) {
if (!hasListPermission && listPerms !== null) {
return <PermissionDeniedFullPage permissionName="role:list" />;
}
if (isAuthZLoading || isLoading) {
return (
<div className={styles.rolesListingTable}>
<Skeleton active paragraph={{ rows: 5 }} />
@@ -267,11 +281,4 @@ function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
);
}
export default withAuthZPage<RolesListContentProps>(RolesListContent, {
checks: [RoleListPermission],
fallbackOnLoading: (
<div className={styles.rolesListingTable}>
<Skeleton active paragraph={{ rows: 5 }} />
</div>
),
});
export default RolesListingTable;

View File

@@ -1,14 +1,11 @@
import { useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import ROUTES from 'constants/routes';
import {
RoleCreatePermission,
RoleListPermission,
} from 'lib/authz/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';
@@ -40,25 +37,24 @@ function RolesSettings(): JSX.Element {
</div>
<div className={styles.rolesSettingsContent}>
<div className={styles.rolesSettingsToolbar}>
<AuthZTooltip checks={[RoleListPermission]}>
<Input
type="search"
placeholder="Search for roles..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
</AuthZTooltip>
<Input
type="search"
placeholder="Search for roles..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
{isRolesEnabled && (
<AuthZButton
checks={[RoleCreatePermission]}
variant="solid"
color="primary"
className={styles.roleSettingsToolbarButton}
onClick={(): void => history.push(ROUTES.ROLE_CREATE)}
>
<Plus size={14} />
Custom role
</AuthZButton>
<AuthZTooltip checks={[RoleCreatePermission]}>
<Button
variant="solid"
color="primary"
className={styles.roleSettingsToolbarButton}
onClick={(): void => history.push(ROUTES.ROLE_CREATE)}
>
<Plus size={14} />
Custom role
</Button>
</AuthZTooltip>
)}
</div>
<RolesListingTable searchQuery={searchQuery} />

View File

@@ -10,17 +10,11 @@ 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 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import { useDeleteRoleModal } from 'container/RolesSettings/DeleteRoleModal/useDeleteRoleModal';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { useRoleAuthZ } from 'container/RolesSettings/hooks/useRoleAuthZ';
import { transformApiToRolePermissions } from 'container/RolesSettings/hooks/useRolePermissions';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { RouterContext } from 'lib/authz/components/withAuthZ/withAuthZ';
import {
buildRoleDeletePermission,
buildRoleReadPermission,
buildRoleUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { RoleType } from 'types/roles';
@@ -33,7 +27,7 @@ import { useViewRolePageActions } from './useViewRolePageActions';
import styles from './ViewRolePage.module.scss';
function ViewRolePageContent(): JSX.Element {
function ViewRolePage(): JSX.Element {
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
const { isRolesEnabled, isLoading: isFeatureGateLoading } =
useRolesFeatureGate();
@@ -51,15 +45,26 @@ function ViewRolePageContent(): JSX.Element {
handleTabChange,
} = useViewRolePageActions();
const {
hasReadPermission,
readRolePermission,
hasUpdatePermission,
updateRolePermission,
hasDeletePermission,
isAuthZLoading,
} = useRoleAuthZ(roleName);
const { data, isLoading, error } = useGetRole(
{ id: roleId ?? '' },
{ query: { enabled: !!roleId } },
{ query: { enabled: !!roleId && hasReadPermission } },
);
const role = data?.data;
const isManaged = role?.type === RoleType.MANAGED;
const {
isDeleteModalOpen,
isDeleteDisabled,
deleteDisabledReason,
deleteError,
handleOpenDeleteModal,
handleCloseDeleteModal,
@@ -67,6 +72,7 @@ function ViewRolePageContent(): JSX.Element {
} = useDeleteRoleModal({
roleId,
isManaged: isManaged ?? false,
hasDeletePermission,
onDeleteSuccess: handleCancel,
});
@@ -138,6 +144,12 @@ function ViewRolePageContent(): JSX.Element {
],
);
if (!hasReadPermission && !isAuthZLoading) {
return (
<PermissionDeniedFullPage permissionName={readRolePermission.object} />
);
}
if (!isRolesEnabled && !isFeatureGateLoading) {
return (
<div className={styles.viewRolePage} data-testid="view-role-page">
@@ -175,7 +187,7 @@ function ViewRolePageContent(): JSX.Element {
);
}
if (isLoading || isFeatureGateLoading) {
if (isAuthZLoading || isLoading || isFeatureGateLoading) {
return (
<div className={styles.viewRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
@@ -232,55 +244,47 @@ function ViewRolePageContent(): JSX.Element {
</div>
<div className={styles.viewRolePageActions}>
{isManaged ? (
<TooltipSimple title="Managed roles cannot be deleted">
<Button
variant="link"
color="destructive"
disabled
data-testid="delete-button"
className={styles.deleteButton}
>
Delete
</Button>
</TooltipSimple>
) : (
<AuthZButton
checks={[buildRoleDeletePermission(roleName)]}
<TooltipSimple
title={isDeleteDisabled ? deleteDisabledReason : 'Open delete modal'}
>
<Button
variant="link"
color="destructive"
onClick={handleOpenDeleteModal}
disabled={isDeleteDisabled}
data-testid="delete-button"
className={styles.deleteButton}
>
Delete
</AuthZButton>
)}
</Button>
</TooltipSimple>
<Divider type="vertical" />
{isManaged ? (
<TooltipSimple title="Managed roles cannot be updated">
<Button
variant="solid"
color="primary"
disabled
data-testid="save-button"
>
Update
</Button>
</TooltipSimple>
) : (
<AuthZButton
checks={[buildRoleUpdatePermission(roleName)]}
<TooltipSimple
title={
isManaged
? 'Managed roles cannot be updated'
: hasUpdatePermission
? 'Open update page'
: `You are not authorized to perform ${updateRolePermission.object}`
}
>
<Button
variant="solid"
color="primary"
data-testid="save-button"
disabled={isManaged || !hasUpdatePermission}
onClick={handleRedirectToUpdate}
style={
isManaged || !hasUpdatePermission
? { pointerEvents: 'auto' }
: undefined
}
>
Update
</AuthZButton>
)}
</Button>
</TooltipSimple>
</div>
</div>
@@ -332,14 +336,4 @@ function ViewRolePageContent(): JSX.Element {
);
}
export default withAuthZPage(ViewRolePageContent, {
checks: (_props: object, router: RouterContext) => {
const roleName = router.searchParams.get('name') ?? '';
return roleName ? [buildRoleReadPermission(roleName)] : [];
},
fallbackOnLoading: (
<div className={styles.viewRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
),
});
export default ViewRolePage;

View File

@@ -37,7 +37,7 @@ describe('ViewRolePage - Actions', () => {
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const cancelBtn = await screen.findByTestId('cancel-button');
const cancelBtn = screen.getByTestId('cancel-button');
await user.click(cancelBtn);
await expect(
@@ -61,10 +61,7 @@ describe('ViewRolePage - Actions', () => {
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const updateBtn = await screen.findByTestId('save-button');
await waitFor(() => {
expect(updateBtn).not.toBeDisabled();
});
const updateBtn = screen.getByTestId('save-button');
await user.click(updateBtn);
await expect(
@@ -79,10 +76,7 @@ describe('ViewRolePage - Actions', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
const deleteBtn = await screen.findByTestId('delete-button');
await waitFor(() => {
expect(deleteBtn).not.toBeDisabled();
});
const deleteBtn = screen.getByTestId('delete-button');
await user.click(deleteBtn);
await expect(
@@ -111,11 +105,7 @@ describe('ViewRolePage - Actions', () => {
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const deleteBtn = await screen.findByTestId('delete-button');
await waitFor(() => {
expect(deleteBtn).not.toBeDisabled();
});
await user.click(deleteBtn);
await user.click(screen.getByTestId('delete-button'));
await expect(
screen.findByText(/Are you sure you want to delete the role/),

View File

@@ -1,17 +1,15 @@
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 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
customRoleResponse,
managedRoleResponse,
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
AUTHZ_CHECK_URL,
setupAuthzAdmin,
setupAuthzDenyAll,
setupAuthzGrantByPrefix,
mockUseAuthZDenyAll,
mockUseAuthZGrantAll,
mockUseAuthZGrantByPrefix,
} from 'lib/authz/utils/authz-test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
@@ -27,15 +25,25 @@ import {
mockPermissionsData,
} from './testUtils';
const mockUseAuthZGrantReadDeleteDenied = mockUseAuthZGrantByPrefix(
'read',
'update',
);
const mockUseAuthZGrantReadUpdateDenied = mockUseAuthZGrantByPrefix(
'read',
'delete',
);
describe('ViewRolePage - AuthZ', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
describe('permission denied', () => {
it('shows permission denied page when read permission denied', async () => {
server.use(setupAuthzDenyAll());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZDenyAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
@@ -55,8 +63,10 @@ describe('ViewRolePage - AuthZ', () => {
});
describe('update button visibility', () => {
it('enables Update button when update permission granted', async () => {
server.use(setupAuthzAdmin());
it('enables Update button when update permission granted', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -82,13 +92,13 @@ describe('ViewRolePage - AuthZ', () => {
},
);
await expect(
screen.findByTestId('save-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('save-button')).toBeInTheDocument();
});
it('disables Update button when update permission denied', async () => {
server.use(setupAuthzGrantByPrefix('read', 'delete'));
it('disables Update button when update permission denied', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadUpdateDenied);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -114,13 +124,13 @@ describe('ViewRolePage - AuthZ', () => {
},
);
await waitFor(() => {
expect(screen.getByTestId('save-button')).toBeDisabled();
});
expect(screen.getByTestId('save-button')).toBeDisabled();
});
it('disables Update button when role is managed', async () => {
server.use(setupAuthzAdmin());
it('disables Update button when role is managed', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
@@ -150,15 +160,15 @@ describe('ViewRolePage - AuthZ', () => {
},
);
await waitFor(() => {
expect(screen.getByTestId('save-button')).toBeDisabled();
});
expect(screen.getByTestId('save-button')).toBeDisabled();
});
it('shows managed role tooltip when update button hovered on managed role', async () => {
const user = userEvent.setup();
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
@@ -188,7 +198,7 @@ describe('ViewRolePage - AuthZ', () => {
},
);
const updateButton = await screen.findByTestId('save-button');
const updateButton = screen.getByTestId('save-button');
await user.hover(updateButton);
await waitFor(() => {
@@ -198,8 +208,12 @@ describe('ViewRolePage - AuthZ', () => {
});
});
it('disables and shows denial attribute when update permission denied', async () => {
server.use(setupAuthzGrantByPrefix('read', 'delete'));
it('shows authorization tooltip when update permission denied', async () => {
const user = userEvent.setup();
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadUpdateDenied);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -225,17 +239,22 @@ describe('ViewRolePage - AuthZ', () => {
},
);
const updateButton = screen.getByTestId('save-button');
await user.hover(updateButton);
await waitFor(() => {
const updateButton = screen.getByTestId('save-button');
expect(updateButton).toBeDisabled();
expect(updateButton).toHaveAttribute('data-denied-permissions');
expect(screen.getByRole('tooltip')).toHaveTextContent(
/You are not authorized to perform/,
);
});
});
});
describe('delete button visibility', () => {
it('disables Delete button when delete permission denied', async () => {
server.use(setupAuthzGrantByPrefix('read', 'update'));
it('disables Delete button when delete permission denied', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadDeleteDenied);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -261,13 +280,13 @@ describe('ViewRolePage - AuthZ', () => {
},
);
await waitFor(() => {
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
it('enables Delete button when delete permission granted', async () => {
server.use(setupAuthzAdmin());
it('enables Delete button when delete permission granted', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -293,13 +312,15 @@ describe('ViewRolePage - AuthZ', () => {
},
);
await waitFor(() => {
expect(screen.getByTestId('delete-button')).not.toBeDisabled();
});
expect(screen.getByTestId('delete-button')).not.toBeDisabled();
});
it('disables and shows denial attribute when delete permission denied', async () => {
server.use(setupAuthzGrantByPrefix('read', 'update'));
it('shows permission denied tooltip when delete permission denied', async () => {
const user = userEvent.setup();
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadDeleteDenied);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -325,17 +346,22 @@ describe('ViewRolePage - AuthZ', () => {
},
);
const deleteButton = screen.getByTestId('delete-button');
await user.hover(deleteButton);
await waitFor(() => {
const deleteButton = screen.getByTestId('delete-button');
expect(deleteButton).toBeDisabled();
expect(deleteButton).toHaveAttribute('data-denied-permissions');
expect(screen.getByRole('tooltip')).toHaveTextContent(
'You do not have permission to delete this role',
);
});
});
it('shows managed role tooltip when role is managed', async () => {
const user = userEvent.setup();
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
@@ -365,7 +391,7 @@ describe('ViewRolePage - AuthZ', () => {
},
);
const deleteButton = await screen.findByTestId('delete-button');
const deleteButton = screen.getByTestId('delete-button');
await user.hover(deleteButton);
await waitFor(() => {
@@ -378,9 +404,13 @@ describe('ViewRolePage - AuthZ', () => {
describe('loading state', () => {
it('shows skeleton while checking permissions', () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
);
jest.spyOn(useAuthZModule, 'useAuthZ').mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
refetchPermissions: jest.fn(),
});
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,

View File

@@ -38,32 +38,28 @@ describe('ViewRolePage - Custom Role', () => {
).resolves.toBeInTheDocument();
});
it('shows Update button for custom roles', async () => {
it('shows Update button for custom roles', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(screen.findByTestId('save-button')).resolves.toBeInTheDocument();
expect(screen.getByTestId('save-button')).toBeInTheDocument();
});
it('shows Cancel button', async () => {
it('shows Cancel button', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('shows Delete button', async () => {
it('shows Delete button', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('delete-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
});
it('renders created/updated timestamps labels', async () => {

View File

@@ -1,7 +1,7 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/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';
@@ -16,12 +16,13 @@ import {
describe('ViewRolePage - Edge Cases', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows fallback for missing description', async () => {
@@ -52,7 +53,7 @@ describe('ViewRolePage - Edge Cases', () => {
await expect(screen.findByText('Description')).resolves.toBeInTheDocument();
});
it('shows fallback for invalid timestamps', async () => {
it('shows fallback for invalid timestamps', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: {
status: 'success',
@@ -78,14 +79,11 @@ describe('ViewRolePage - Edge Cases', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('view-role-page'),
).resolves.toBeInTheDocument();
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(2);
});
it('shows fallback for undefined timestamps', async () => {
it('shows fallback for undefined timestamps', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: {
status: 'success',
@@ -111,9 +109,6 @@ describe('ViewRolePage - Edge Cases', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('view-role-page'),
).resolves.toBeInTheDocument();
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(2);
});

View File

@@ -1,10 +1,10 @@
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 'lib/authz/hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { render, screen } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
import ViewRolePage from '../ViewRolePage';
@@ -18,15 +18,16 @@ import {
describe('ViewRolePage - Error State', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('displays error component when API has error but role data exists', async () => {
it('displays error component when API has error but role data exists', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
@@ -45,9 +46,7 @@ describe('ViewRolePage - Error State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitFor(() => {
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
it('displays error state with title when API fails without role data', async () => {
@@ -65,12 +64,10 @@ describe('ViewRolePage - Error State', () => {
await expect(
screen.findByText('Failed to load role'),
).resolves.toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
it('shows back button on error state', async () => {
it('shows back button on error state', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
@@ -82,9 +79,7 @@ describe('ViewRolePage - Error State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('navigates to roles list when back button clicked on error state', async () => {
@@ -110,7 +105,7 @@ describe('ViewRolePage - Error State', () => {
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const cancelButton = await screen.findByTestId('cancel-button');
const cancelButton = screen.getByTestId('cancel-button');
await user.click(cancelButton);
await expect(

View File

@@ -1,10 +1,10 @@
import * as roleApi from 'api/generated/services/role';
import { FeatureKeys } from 'constants/features';
import { server } from 'mocks-server/server';
import { defaultFeatureFlags, render, screen, waitFor } from 'tests/test-utils';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
import {
invalidLicense,
setupAuthzAdmin,
mockUseAuthZGrantAll,
} from 'lib/authz/utils/authz-test-utils';
import ViewRolePage from '../ViewRolePage';
@@ -17,7 +17,9 @@ import {
describe('ViewRolePage - Feature Gate', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
@@ -29,7 +31,6 @@ describe('ViewRolePage - Feature Gate', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
describe('feature disabled', () => {
@@ -45,9 +46,7 @@ describe('ViewRolePage - Feature Gate', () => {
},
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
@@ -59,34 +58,28 @@ describe('ViewRolePage - Feature Gate', () => {
appContextOverrides: { activeLicense: invalidLicense },
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows back button when feature disabled', async () => {
it('shows back button when feature disabled', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: { activeLicense: invalidLicense },
});
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('back button is enabled when feature disabled', async () => {
it('back button is enabled when feature disabled', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: { activeLicense: invalidLicense },
});
await waitFor(() => {
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
});
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
});
});
});

View File

@@ -1,6 +1,6 @@
import * as roleApi from 'api/generated/services/role';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/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';
@@ -13,12 +13,13 @@ import {
describe('ViewRolePage - Loading State', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows skeleton while fetching role', () => {

View File

@@ -1,5 +1,5 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen, waitFor } from 'tests/test-utils';
import { render, screen } from 'tests/test-utils';
import ViewRolePage from '../ViewRolePage';
@@ -19,7 +19,7 @@ describe('ViewRolePage - Managed Role', () => {
jest.restoreAllMocks();
});
it('disables Delete button for managed roles', async () => {
it('disables Delete button for managed roles', () => {
render(
<TooltipProvider>
<ViewRolePage />
@@ -30,12 +30,10 @@ describe('ViewRolePage - Managed Role', () => {
},
);
await waitFor(() => {
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
it('disables Update button for managed roles', async () => {
it('disables Update button for managed roles', () => {
render(
<TooltipProvider>
<ViewRolePage />
@@ -46,12 +44,10 @@ describe('ViewRolePage - Managed Role', () => {
},
);
await waitFor(() => {
expect(screen.getByTestId('save-button')).toBeDisabled();
});
expect(screen.getByTestId('save-button')).toBeDisabled();
});
it('still shows Cancel button for managed roles', async () => {
it('still shows Cancel button for managed roles', () => {
render(
<TooltipProvider>
<ViewRolePage />
@@ -62,8 +58,6 @@ describe('ViewRolePage - Managed Role', () => {
},
);
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/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';
@@ -17,15 +17,8 @@ import {
mockPermissionsData,
} from './testUtils';
async function waitForPageReady(): Promise<void> {
await expect(
screen.findByTestId('view-role-page'),
).resolves.toBeInTheDocument();
}
async function expandAllCards(): Promise<void> {
const user = userEvent.setup();
await waitForPageReady();
const expandButton = screen.getByTestId('expand-all-button');
await user.click(expandButton);
}
@@ -37,7 +30,6 @@ describe('ViewRolePage - Permission Overview', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders Transaction Groups section label', async () => {
@@ -50,21 +42,19 @@ describe('ViewRolePage - Permission Overview', () => {
).resolves.toBeInTheDocument();
});
it('renders permission overview container', async () => {
it('renders permission overview container', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
});
it('shows resource permission cards', async () => {
it('shows resource permission cards', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(
screen.getByTestId('resource-section-factor-api-key'),
).toBeInTheDocument();
@@ -74,12 +64,11 @@ describe('ViewRolePage - Permission Overview', () => {
).toBeInTheDocument();
});
it('displays granted count for each resource', async () => {
it('displays granted count for each resource', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(
screen.getByTestId('granted-count-factor-api-key'),
).toBeInTheDocument();
@@ -88,15 +77,16 @@ describe('ViewRolePage - Permission Overview', () => {
describe('ViewRolePage - Permission Overview Loading State', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows skeleton when permissions are loading', async () => {
it('shows skeleton when permissions are loading', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
@@ -115,22 +105,22 @@ describe('ViewRolePage - Permission Overview Loading State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-overview-loading')).toBeInTheDocument();
});
});
describe('ViewRolePage - Permission Overview Error State', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows error when permissions fail to load', async () => {
it('shows error when permissions fail to load', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
@@ -149,19 +139,19 @@ describe('ViewRolePage - Permission Overview Error State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-overview-error')).toBeInTheDocument();
});
});
describe('ViewRolePage - Scope: ALL permissions', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows "All" badge for actions with ALL scope', async () => {
@@ -192,7 +182,7 @@ describe('ViewRolePage - Scope: ALL permissions', () => {
expect(screen.getByTestId('scope-badge-create')).toHaveTextContent('All');
});
it('shows full granted count when all actions are ALL', async () => {
it('shows full granted count when all actions are ALL', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -215,7 +205,6 @@ describe('ViewRolePage - Scope: ALL permissions', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('granted-count-role')).toHaveTextContent(
'3 / 3 granted',
);
@@ -223,13 +212,8 @@ describe('ViewRolePage - Scope: ALL permissions', () => {
});
describe('ViewRolePage - Scope: NONE permissions', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows "None" badge for actions with NONE scope', async () => {
@@ -260,7 +244,7 @@ describe('ViewRolePage - Scope: NONE permissions', () => {
expect(screen.getByTestId('scope-badge-delete')).toHaveTextContent('None');
});
it('shows zero granted count when all actions are NONE', async () => {
it('shows zero granted count when all actions are NONE', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -284,7 +268,6 @@ describe('ViewRolePage - Scope: NONE permissions', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
'0 / 4 granted',
);
@@ -292,13 +275,8 @@ describe('ViewRolePage - Scope: NONE permissions', () => {
});
describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows "Only selected" badge with count', async () => {
@@ -362,7 +340,7 @@ describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
await expect(screen.findByText('key-def-456')).resolves.toBeInTheDocument();
});
it('counts ONLY_SELECTED as granted in count', async () => {
it('counts ONLY_SELECTED as granted in count', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -384,7 +362,6 @@ describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('granted-count-serviceaccount')).toHaveTextContent(
'1 / 2 granted',
);
@@ -431,13 +408,8 @@ describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
});
describe('ViewRolePage - Mixed permission scopes', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders all three scope types in single resource card', async () => {
@@ -486,7 +458,7 @@ describe('ViewRolePage - Mixed permission scopes', () => {
);
});
it('renders multiple resources with different scope combinations', async () => {
it('renders multiple resources with different scope combinations', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -530,7 +502,6 @@ describe('ViewRolePage - Mixed permission scopes', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
'2 / 2 granted',
);
@@ -544,13 +515,8 @@ describe('ViewRolePage - Mixed permission scopes', () => {
});
describe('ViewRolePage - Unknown resources', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders unknown resource with fallback label', async () => {
@@ -574,7 +540,6 @@ describe('ViewRolePage - Unknown resources', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(
screen.getByTestId('resource-section-future-resource'),
).toBeInTheDocument();
@@ -611,7 +576,7 @@ describe('ViewRolePage - Unknown resources', () => {
).resolves.toBeInTheDocument();
});
it('handles resource with empty actions', async () => {
it('handles resource with empty actions', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -630,7 +595,6 @@ describe('ViewRolePage - Unknown resources', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(
screen.getByTestId('resource-section-empty-resource'),
).toBeInTheDocument();
@@ -647,15 +611,13 @@ describe('ViewRolePage - View mode toggle', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders Interactive/JSON toggle', async () => {
it('renders Interactive/JSON toggle', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-view-mode-list')).toBeInTheDocument();
expect(screen.getByTestId('permission-view-mode-json')).toBeInTheDocument();
});
@@ -667,7 +629,6 @@ describe('ViewRolePage - View mode toggle', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
const jsonToggle = screen.getByTestId('permission-view-mode-json');
@@ -684,7 +645,6 @@ describe('ViewRolePage - JSON Viewer Copy Button', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders copy button in JSON view', async () => {
@@ -694,7 +654,6 @@ describe('ViewRolePage - JSON Viewer Copy Button', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
const jsonToggle = screen.getByTestId('permission-view-mode-json');
await user.click(jsonToggle);
@@ -710,7 +669,6 @@ describe('ViewRolePage - JSON Viewer Copy Button', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
const jsonToggle = screen.getByTestId('permission-view-mode-json');
await user.click(jsonToggle);

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 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
customRoleResponse,
managedRoleResponse,
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
@@ -79,7 +79,9 @@ export const mockPermissionsData = {
};
export function mockHooksForCustomRole(): void {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -97,7 +99,9 @@ export function mockHooksForCustomRole(): void {
}
export function mockHooksWithPermissions(permissions: unknown): void {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -115,7 +119,9 @@ export function mockHooksWithPermissions(permissions: unknown): void {
}
export function mockHooksForManagedRole(): void {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,

View File

@@ -11,21 +11,23 @@ import {
userEvent,
} from 'tests/test-utils';
import { FeatureKeys } from 'constants/features';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
invalidLicense,
setupAuthzAdmin,
setupAuthzDeny,
mockUseAuthZGrantAll,
} from 'lib/authz/utils/authz-test-utils';
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import RolesSettings from '../RolesSettings';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const rolesApiURL = 'http://localhost/api/v1/roles';
describe('RolesSettings', () => {
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
server.use(
setupAuthzAdmin(),
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
@@ -33,6 +35,7 @@ describe('RolesSettings', () => {
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
@@ -270,18 +273,4 @@ describe('RolesSettings', () => {
// Total dashes expected: 2 (for both dates)
expect(dashFallback.length).toBeGreaterThanOrEqual(2);
});
it('disables search input when user lacks list permission', async () => {
server.use(
setupAuthzDeny(RoleListPermission),
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
const searchInput = await screen.findByPlaceholderText('Search for roles...');
expect(searchInput).toBeDisabled();
});
});

View File

@@ -0,0 +1,88 @@
import { useMemo } from 'react';
import {
buildRoleDeletePermission,
buildRoleReadPermission,
buildRoleUpdatePermission,
RoleCreatePermission,
} 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;
updateRolePermission: ParsedPermissionObject;
deleteRolePermission: ParsedPermissionObject;
hasCreatePermission: boolean;
hasReadPermission: boolean;
hasUpdatePermission: boolean;
hasDeletePermission: boolean;
isAuthZLoading: boolean;
}
export function useRoleAuthZ(roleName: string): UseRoleAuthZResult {
const readRolePermissionName = buildRoleReadPermission(roleName);
const updateRolePermissionName = buildRoleUpdatePermission(roleName);
const deleteRolePermissionName = buildRoleDeletePermission(roleName);
const permissionsToCheck = useMemo(() => {
const perms = [RoleCreatePermission];
if (roleName) {
perms.push(
readRolePermissionName,
updateRolePermissionName,
deleteRolePermissionName,
);
}
return perms;
}, [
roleName,
readRolePermissionName,
updateRolePermissionName,
deleteRolePermissionName,
]);
const { permissions, isLoading: isAuthZLoading } =
useAuthZ(permissionsToCheck);
const hasCreatePermission = useMemo(() => {
if (permissions === null) {
return false;
}
return permissions[RoleCreatePermission]?.isGranted ?? false;
}, [permissions]);
const hasReadPermission = useMemo(() => {
if (!roleName || permissions === null) {
return true;
}
return permissions[buildRoleReadPermission(roleName)]?.isGranted ?? true;
}, [permissions, roleName]);
const hasUpdatePermission = useMemo(() => {
if (!roleName || permissions === null) {
return true;
}
return permissions[buildRoleUpdatePermission(roleName)]?.isGranted ?? true;
}, [permissions, roleName]);
const hasDeletePermission = useMemo(() => {
if (!roleName || permissions === null) {
return true;
}
return permissions[buildRoleDeletePermission(roleName)]?.isGranted ?? true;
}, [permissions, roleName]);
return {
readRolePermission: parsePermission(readRolePermissionName),
updateRolePermission: parsePermission(updateRolePermissionName),
deleteRolePermission: parsePermission(deleteRolePermissionName),
hasCreatePermission,
hasReadPermission,
hasUpdatePermission,
hasDeletePermission,
isAuthZLoading,
};
}

View File

@@ -1,28 +0,0 @@
.container {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 9998;
pointer-events: auto;
display: flex;
align-items: center;
gap: 4px;
}
.button {
box-shadow:
0 4px 12px rgb(0 0 0 / 15%),
0 0 0 1px rgb(0 0 0 / 5%);
}
.badge {
margin-left: 4px;
}
.closeButton {
border-radius: 4px;
box-shadow:
0 4px 12px rgb(0 0 0 / 15%),
0 0 0 1px rgb(0 0 0 / 5%);
}

View File

@@ -1,61 +0,0 @@
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"
prefix={<X />}
/>
</div>,
document.body,
);
}

View File

@@ -1,140 +0,0 @@
.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

@@ -1,108 +0,0 @@
import { DialogWrapper } from '@signozhq/ui/dialog';
import { useCallback, useRef } from 'react';
import { useAuthZDevStore } from '../useAuthZDevStore';
import { useAuthZQueryInvalidation } from '../useAuthZQueryInvalidation';
import { AuthZDevModalContent } from './AuthZDevModalContent';
import { AuthZDevModalFooter } from './AuthZDevModalFooter';
import { AuthZDevModalHeader } from './AuthZDevModalHeader';
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],
);
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}>
<AuthZDevModalHeader
searchInputRef={searchInputRef}
search={search}
setSearch={setSearch}
resourceFilter={resourceFilter}
setResourceFilter={setResourceFilter}
resourceFilterItems={resourceFilterItems}
hasActiveFilter={hasActiveFilter}
filteredPermissions={filteredPermissions}
filteredOverrideCount={filteredOverrideCount}
overrideCount={overrideCount}
grantAll={grantAll}
denyAll={denyAll}
clearAllOverrides={clearAllOverrides}
/>
<AuthZDevModalContent
observedListLength={observedList.length}
orderedPermissions={orderedPermissions}
groups={groups}
indexByPermission={indexByPermission}
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
observed={observed}
overrides={overrides}
onSetOverride={setOverride}
/>
<AuthZDevModalFooter
orderedPermissionsCount={orderedPermissions.length}
observedListLength={observedList.length}
/>
</div>
</DialogWrapper>
);
}

View File

@@ -1,85 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { useCallback } from 'react';
import { BrandedPermission } from '../../hooks/useAuthZ/types';
import { ObservedPermission, OverrideState } from '../types';
import { PermissionRow } from './PermissionRow';
import styles from './AuthZDevModal.module.css';
export interface PermissionGroup {
resource: string;
items: string[];
}
export interface AuthZDevModalContentProps {
observedListLength: number;
orderedPermissions: string[];
groups: PermissionGroup[];
indexByPermission: Map<string, number>;
selectedIndex: number;
setSelectedIndex: (index: number) => void;
observed: Record<string, ObservedPermission>;
overrides: Record<string, OverrideState>;
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
}
export function AuthZDevModalContent({
observedListLength,
orderedPermissions,
groups,
indexByPermission,
selectedIndex,
setSelectedIndex,
observed,
overrides,
onSetOverride,
}: AuthZDevModalContentProps): JSX.Element {
const handleSelectIndex = useCallback(
(index: number) => (): void => {
setSelectedIndex(index);
},
[setSelectedIndex],
);
return (
<div className={styles.list} data-testid="authz-dev-permission-list">
{orderedPermissions.length === 0 ? (
<div className={styles.empty}>
<Typography.Text align="center" color="muted">
{observedListLength === 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={onSetOverride}
onSelect={handleSelectIndex(index)}
/>
);
})}
</div>
))
)}
</div>
);
}

View File

@@ -1,56 +0,0 @@
import { Kbd } from '@signozhq/ui/kbd';
import { Typography } from '@signozhq/ui/typography';
import styles from './AuthZDevModal.module.css';
export interface AuthZDevModalFooterProps {
orderedPermissionsCount: number;
observedListLength: number;
}
export function AuthZDevModalFooter({
orderedPermissionsCount,
observedListLength,
}: AuthZDevModalFooterProps): JSX.Element {
return (
<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}>
{orderedPermissionsCount} of {observedListLength} permissions
</Typography.Text>
</div>
);
}

View File

@@ -1,119 +0,0 @@
import { Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { RefObject, useCallback } from 'react';
import { BrandedPermission } from '../../hooks/useAuthZ/types';
import styles from './AuthZDevModal.module.css';
export interface AuthZDevModalHeaderProps {
searchInputRef: RefObject<HTMLInputElement>;
search: string;
setSearch: (value: string) => void;
resourceFilter: string;
setResourceFilter: (value: string) => void;
resourceFilterItems: Array<{ value: string; label: string }>;
hasActiveFilter: boolean;
filteredPermissions: BrandedPermission[];
filteredOverrideCount: number;
overrideCount: number;
grantAll: (permissions?: BrandedPermission[]) => void;
denyAll: (permissions?: BrandedPermission[]) => void;
clearAllOverrides: (permissions?: BrandedPermission[]) => void;
}
export function AuthZDevModalHeader({
searchInputRef,
search,
setSearch,
resourceFilter,
setResourceFilter,
resourceFilterItems,
hasActiveFilter,
filteredPermissions,
filteredOverrideCount,
overrideCount,
grantAll,
denyAll,
clearAllOverrides,
}: AuthZDevModalHeaderProps): JSX.Element {
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]);
return (
<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>
);
}

View File

@@ -1,72 +0,0 @@
.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 {
--button-height: 22px;
--button-padding: 0 var(--spacing-3);
--button-secondary-ghost-hover-foreground: var(--l2-foreground);
--button-variant-ghost-background-color: transparent;
border: none;
--button-border-radius: calc(var(--radius-2) - 1px);
transition:
background 120ms ease,
color 120ms ease;
}
.segment:not(.segmentActive):hover {
--button-secondary-ghost-hover-foreground: var(--l2-foreground-hover);
--button-variant-ghost-background-color: var(--l2-background);
}
.segmentIcon {
display: inline-flex;
align-items: center;
}
.segment.optAuto {
--button-secondary-ghost-hover-foreground: var(--l1-foreground);
--button-variant-ghost-background-color: var(--l3-background);
}
.segment.optGranted {
--button-secondary-ghost-hover-foreground: var(--success-foreground);
--button-variant-ghost-background-color: color-mix(
in srgb,
var(--accent-forest) 22%,
transparent
);
}
.segment.optDenied {
--button-secondary-ghost-hover-foreground: var(--danger-foreground);
--button-variant-ghost-background-color: color-mix(
in srgb,
var(--accent-cherry) 22%,
transparent
);
}
.segment.optDelay {
--button-secondary-ghost-hover-foreground: var(--warning-foreground);
--button-variant-ghost-background-color: color-mix(
in srgb,
var(--accent-amber) 22%,
transparent
);
}
.segment.optError {
--button-secondary-ghost-hover-foreground: var(--danger-foreground);
--button-variant-ghost-background-color: color-mix(
in srgb,
var(--accent-cherry) 22%,
transparent
);
}

View File

@@ -1,93 +0,0 @@
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 { OverrideState } from '../types';
import styles from './OverrideControl.module.css';
import { Button } from '@signozhq/ui/button';
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: OverrideState.Reset,
label: 'Auto',
icon: <RotateCcw size={13} />,
activeClassName: styles.optAuto,
},
{
state: OverrideState.Granted,
label: 'Grant',
icon: <Check size={13} />,
activeClassName: styles.optGranted,
},
{
state: OverrideState.Denied,
label: 'Deny',
icon: <X size={13} />,
activeClassName: styles.optDenied,
},
{
state: OverrideState.Delay,
label: 'Delay',
icon: <Clock size={13} />,
activeClassName: styles.optDelay,
},
{
state: OverrideState.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,
})}
variant="ghost"
color="secondary"
onClick={(): void => onSelect(permission, option.state)}
data-testid={`override-${option.state}-${permission}`}
>
<div className={styles.segmentIcon}>{option.icon}</div>
{isActive && (
<Typography.Text as="span" size="small" weight="medium">
{option.label}
</Typography.Text>
)}
</Button>
);
})}
</div>
);
}

View File

@@ -1,68 +0,0 @@
.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

@@ -1,114 +0,0 @@
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 { OverrideState, type ObservedPermission } 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> = {
[OverrideState.Reset]: null,
[OverrideState.Granted]: styles.rowGranted,
[OverrideState.Denied]: styles.rowDenied,
[OverrideState.Delay]: styles.rowDelay,
[OverrideState.Error]: styles.rowError,
};
export const PermissionRow = memo(function PermissionRow({
observed,
override,
isSelected,
onSetOverride,
onSelect,
}: PermissionRowProps): JSX.Element {
const currentState = override ?? OverrideState.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>
);
});

View File

@@ -1,172 +0,0 @@
import { useMemo, useState } from 'react';
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
import { parsePermission } from '../../hooks/useAuthZ/utils';
import type { ObservedPermission, OverrideState } from '../types';
type SelectItem = {
value: string;
label: string;
};
type PermissionGroup = {
resource: string;
items: BrandedPermission[];
};
type UseAuthZDevModalDataResult = {
search: string;
setSearch: (search: string) => void;
resourceFilter: string;
setResourceFilter: (filter: string) => void;
observedList: ObservedPermission[];
resourceFilterItems: SelectItem[];
filteredPermissions: BrandedPermission[];
groups: PermissionGroup[];
orderedPermissions: BrandedPermission[];
indexByPermission: Map<string, number>;
hasActiveFilter: boolean;
filteredOverrideCount: number;
overrideCount: number;
};
export function useAuthZDevModalData(
observed: Record<string, ObservedPermission>,
overrides: Record<string, OverrideState>,
): UseAuthZDevModalDataResult {
const [search, setSearch] = useState('');
const [resourceFilter, setResourceFilter] = useState<string>('all');
const observedList = useMemo(
() =>
Object.values(observed).sort((a, b) =>
a.permission.localeCompare(b.permission),
),
[observed],
);
const resources = useMemo(() => {
const resourceSet = new Set<string>();
for (const obs of observedList) {
const { object } = parsePermission(obs.permission);
const resource = object.split(':')[0];
resourceSet.add(resource);
}
return Array.from(resourceSet).sort();
}, [observedList]);
const resourceFilterItems = useMemo<SelectItem[]>(
() => [
{ value: 'all', label: 'All resources' },
...resources.map((resource) => ({
value: resource,
label: resource,
})),
],
[resources],
);
const filteredPermissions = useMemo(() => {
let filtered = observedList;
if (search) {
const searchLower = search.toLowerCase();
filtered = filtered.filter((obs) =>
obs.permission.toLowerCase().includes(searchLower),
);
}
if (resourceFilter !== 'all') {
filtered = filtered.filter((obs) => {
const { object } = parsePermission(obs.permission);
const resource = object.split(':')[0];
return resource === resourceFilter;
});
}
return filtered.map((obs) => obs.permission);
}, [observedList, search, resourceFilter]);
const { groups, orderedPermissions } = useMemo(() => {
const groupMap = new Map<string, BrandedPermission[]>();
for (const permission of filteredPermissions) {
const { object } = parsePermission(permission);
const resource = object.split(':')[0] || 'other';
const bucket = groupMap.get(resource);
if (bucket) {
bucket.push(permission);
} else {
groupMap.set(resource, [permission]);
}
}
const sortItems = (items: BrandedPermission[]): BrandedPermission[] =>
[...items].sort((a, b) => {
const objA = parsePermission(a).object;
const objB = parsePermission(b).object;
const idA = objA.split(':')[1] ?? '';
const idB = objB.split(':')[1] ?? '';
const isWildcardA = idA === '*';
const isWildcardB = idB === '*';
// Wildcards first
if (isWildcardA && !isWildcardB) {
return -1;
}
if (!isWildcardA && isWildcardB) {
return 1;
}
// Then by object ID, then by full permission
const idCompare = idA.localeCompare(idB);
if (idCompare !== 0) {
return idCompare;
}
return a.localeCompare(b);
});
const sortedGroups = Array.from(groupMap, ([resource, items]) => ({
resource,
items: sortItems(items),
})).sort((a, b) => a.resource.localeCompare(b.resource));
return {
groups: sortedGroups,
orderedPermissions: sortedGroups.flatMap((group) => group.items),
};
}, [filteredPermissions]);
const indexByPermission = useMemo(() => {
const map = new Map<string, number>();
orderedPermissions.forEach((permission, index) => {
map.set(permission, index);
});
return map;
}, [orderedPermissions]);
const hasActiveFilter = search !== '' || resourceFilter !== 'all';
const filteredOverrideCount = useMemo(() => {
if (!hasActiveFilter) {
return Object.keys(overrides).length;
}
return filteredPermissions.filter((p) => p in overrides).length;
}, [hasActiveFilter, overrides, filteredPermissions]);
const overrideCount = Object.keys(overrides).length;
return {
search,
setSearch,
resourceFilter,
setResourceFilter,
observedList,
resourceFilterItems,
filteredPermissions,
groups,
orderedPermissions,
indexByPermission,
hasActiveFilter,
filteredOverrideCount,
overrideCount,
};
}

View File

@@ -1,174 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
import { OverrideState, OVERRIDE_CYCLE } from '../types';
type UseModalKeyboardOptions = {
permissions: BrandedPermission[];
overrides: Record<string, OverrideState>;
onCycle: (permission: BrandedPermission) => void;
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
onClose: () => void;
searchInputRef: React.RefObject<HTMLInputElement | null>;
};
type UseModalKeyboardResult = {
selectedIndex: number;
setSelectedIndex: (index: number) => void;
};
type KeyContext = {
permissions: BrandedPermission[];
overrides: Record<string, OverrideState>;
selectedIndex: number;
setSelectedIndex: React.Dispatch<React.SetStateAction<number>>;
onCycle: (permission: BrandedPermission) => void;
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
};
const ARROW_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
const NUMBER_KEY_INDEX: Record<string, number> = {
'1': 0,
'2': 1,
'3': 2,
'4': 3,
'5': 4,
};
function stepOverrideState(
current: OverrideState,
direction: number,
): OverrideState {
const currentIndex = OVERRIDE_CYCLE.indexOf(current);
const nextIndex =
(currentIndex + direction + OVERRIDE_CYCLE.length) % OVERRIDE_CYCLE.length;
return OVERRIDE_CYCLE[nextIndex];
}
// Arrow keys stay active even while the search input is focused so the list can
// be driven without leaving the search field.
function handleArrowKey(key: string, ctx: KeyContext): void {
if (key === 'ArrowDown') {
ctx.setSelectedIndex((prev) =>
Math.min(prev + 1, ctx.permissions.length - 1),
);
return;
}
if (key === 'ArrowUp') {
ctx.setSelectedIndex((prev) => Math.max(prev - 1, 0));
return;
}
const selected = ctx.permissions[ctx.selectedIndex];
if (!selected) {
return;
}
const direction = key === 'ArrowLeft' ? -1 : 1;
ctx.onSetOverride(
selected,
stepOverrideState(ctx.overrides[selected] ?? OverrideState.Reset, direction),
);
}
// Number and space/enter shortcuts type into the search field, so they only run
// when it is not focused. Returns whether the key was handled.
function handleActionKey(key: string, ctx: KeyContext): boolean {
const selected = ctx.permissions[ctx.selectedIndex];
const numberIndex = NUMBER_KEY_INDEX[key];
if (numberIndex !== undefined) {
if (selected) {
ctx.onSetOverride(selected, OVERRIDE_CYCLE[numberIndex]);
}
return true;
}
if (key === ' ' || key === 'Enter') {
if (selected) {
ctx.onCycle(selected);
}
return true;
}
return false;
}
export function useModalKeyboard({
permissions,
overrides,
onCycle,
onSetOverride,
onClose,
searchInputRef,
}: UseModalKeyboardOptions): UseModalKeyboardResult {
// Start with no selection (-1) to avoid accidental override changes from
// Enter keypress that opened the modal also triggering cycleOverride.
const [selectedIndex, setSelectedIndex] = useState(-1);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const isSearchFocused = document.activeElement === searchInputRef.current;
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
if (e.key === '/') {
if (!isSearchFocused) {
e.preventDefault();
searchInputRef.current?.focus();
}
return;
}
const ctx: KeyContext = {
permissions,
overrides,
selectedIndex,
setSelectedIndex,
onCycle,
onSetOverride,
};
if (ARROW_KEYS.has(e.key)) {
e.preventDefault();
handleArrowKey(e.key, ctx);
return;
}
if (isSearchFocused) {
return;
}
if (handleActionKey(e.key, ctx)) {
e.preventDefault();
}
},
[
permissions,
overrides,
selectedIndex,
onCycle,
onSetOverride,
onClose,
searchInputRef,
],
);
useEffect((): (() => void) => {
window.addEventListener('keydown', handleKeyDown);
return (): void => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
useEffect((): void => {
if (selectedIndex >= permissions.length && permissions.length > 0) {
setSelectedIndex(permissions.length - 1);
}
}, [permissions.length, selectedIndex]);
return {
selectedIndex,
setSelectedIndex,
};
}

View File

@@ -1,46 +0,0 @@
import type { BrandedPermission } from '../hooks/useAuthZ/types';
export enum OverrideState {
Granted = 'granted',
Denied = 'denied',
Delay = 'delay',
Error = 'error',
Reset = 'reset',
}
export type ObservedPermission = {
permission: BrandedPermission;
apiValue: boolean | null;
lastSeen: number;
};
export type PermissionOverride = {
permission: BrandedPermission;
state: OverrideState;
};
export type AuthZDevStore = {
isModalOpen: boolean;
observed: Record<string, ObservedPermission>;
overrides: Record<string, OverrideState>;
openModal: () => void;
closeModal: () => void;
toggleModal: () => void;
registerObserved: (permission: BrandedPermission, apiValue: boolean) => void;
setOverride: (permission: BrandedPermission, state: OverrideState) => void;
clearOverride: (permission: BrandedPermission) => void;
clearAllOverrides: (permissions?: BrandedPermission[]) => void;
grantAll: (permissions?: BrandedPermission[]) => void;
denyAll: (permissions?: BrandedPermission[]) => void;
cycleOverride: (permission: BrandedPermission) => void;
};
export const OVERRIDE_CYCLE: OverrideState[] = [
OverrideState.Reset,
OverrideState.Granted,
OverrideState.Denied,
OverrideState.Delay,
OverrideState.Error,
];

View File

@@ -1,137 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { BrandedPermission } from '../hooks/useAuthZ/types';
import { OverrideState, OVERRIDE_CYCLE, type AuthZDevStore } from './types';
import { getScopedKey } from 'utils/storage';
export const useAuthZDevStore = create<AuthZDevStore>()(
persist(
(set, get) => ({
isModalOpen: false,
observed: {},
overrides: {},
openModal: (): void => {
set({ isModalOpen: true });
},
closeModal: (): void => {
set({ isModalOpen: false });
},
toggleModal: (): void => {
set((state) => ({ isModalOpen: !state.isModalOpen }));
},
registerObserved: (
permission: BrandedPermission,
apiValue: boolean,
): void => {
set((state) => ({
observed: {
...state.observed,
[permission]: {
permission,
apiValue,
lastSeen: Date.now(),
},
},
}));
},
setOverride: (permission: BrandedPermission, state: OverrideState): void => {
if (state === OverrideState.Reset) {
get().clearOverride(permission);
return;
}
set((s) => ({
overrides: {
...s.overrides,
[permission]: state,
},
}));
},
clearOverride: (permission: BrandedPermission): void => {
set((state) => {
const { [permission]: _, ...rest } = state.overrides;
return { overrides: rest };
});
},
clearAllOverrides: (permissions?: BrandedPermission[]): void => {
if (permissions) {
set((state) => {
const newOverrides = { ...state.overrides };
for (const permission of permissions) {
delete newOverrides[permission];
}
return { overrides: newOverrides };
});
} else {
set({ overrides: {} });
}
},
grantAll: (permissions?: BrandedPermission[]): void => {
set((state) => {
const keys = permissions ?? Object.keys(state.observed);
const newOverrides: Record<string, OverrideState> = {
...state.overrides,
};
for (const key of keys) {
newOverrides[key] = OverrideState.Granted;
}
return { overrides: newOverrides };
});
},
denyAll: (permissions?: BrandedPermission[]): void => {
set((state) => {
const keys = permissions ?? Object.keys(state.observed);
const newOverrides: Record<string, OverrideState> = {
...state.overrides,
};
for (const key of keys) {
newOverrides[key] = OverrideState.Denied;
}
return { overrides: newOverrides };
});
},
cycleOverride: (permission: BrandedPermission): void => {
const currentOverride = get().overrides[permission] ?? OverrideState.Reset;
const currentIndex = OVERRIDE_CYCLE.indexOf(currentOverride);
const nextIndex = (currentIndex + 1) % OVERRIDE_CYCLE.length;
const nextState = OVERRIDE_CYCLE[nextIndex];
get().setOverride(permission, nextState);
},
}),
{
name: `@signoz/${getScopedKey('authz-dev-overrides')}`,
partialize: (state) => {
// Clear apiValue for permissions without active override (auto mode)
// since the API value can change between sessions
const observed: typeof state.observed = {};
for (const [key, obs] of Object.entries(state.observed)) {
observed[key] = {
...obs,
apiValue: key in state.overrides ? obs.apiValue : null,
};
}
return {
observed,
overrides: state.overrides,
};
},
},
),
);
export const openAuthZDevModal = (): void =>
useAuthZDevStore.getState().openModal();
export const closeAuthZDevModal = (): void =>
useAuthZDevStore.getState().closeModal();
export const toggleAuthZDevModal = (): void =>
useAuthZDevStore.getState().toggleModal();

View File

@@ -1,28 +0,0 @@
import { useEffect, useRef } from 'react';
import { useQueryClient } from 'react-query';
import type { OverrideState } from './types';
type Overrides = Record<string, OverrideState>;
export function useAuthZQueryInvalidation(overrides: Overrides): void {
const queryClient = useQueryClient();
const prevOverridesRef = useRef<Overrides>(overrides);
useEffect(() => {
const prevOverrides = prevOverridesRef.current;
prevOverridesRef.current = overrides;
const allKeys = new Set([
...Object.keys(prevOverrides),
...Object.keys(overrides),
]);
for (const key of allKeys) {
if (prevOverrides[key] !== overrides[key]) {
// Reset query to initial state and trigger refetch for active observers
void queryClient.resetQueries(['authz', key]);
}
}
}, [overrides, queryClient]);
}

View File

@@ -89,13 +89,5 @@ export type UseAuthZResult = {
isFetching: boolean;
error: Error | null;
permissions: AuthZCheckResponse | null;
/**
* True if every check is granted. False while loading or on error.
*/
allowed: boolean;
/**
* Checks that resolved as not granted (empty while loading/error).
*/
deniedPermissions: BrandedPermission[];
refetchPermissions: () => void;
};

View File

@@ -1,6 +1,5 @@
import { ReactElement } from 'react';
import { renderHook, waitFor, act } from '@testing-library/react';
import { useQueryClient } from 'react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { AllTheProviders } from 'tests/test-utils';
@@ -47,16 +46,12 @@ describe('useAuthZ', () => {
expect(result.current.isLoading).toBe(true);
expect(result.current.permissions).toBeNull();
expect(result.current.allowed).toBe(false);
expect(result.current.deniedPermissions).toStrictEqual([]);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.permissions).toStrictEqual(expectedResponse);
expect(result.current.allowed).toBe(false);
expect(result.current.deniedPermissions).toStrictEqual([permission2]);
});
it('should return error and null permissions when API errors', async () => {
@@ -78,89 +73,6 @@ describe('useAuthZ', () => {
expect(result.current.error).not.toBeNull();
expect(result.current.permissions).toBeNull();
expect(result.current.allowed).toBe(false);
expect(result.current.deniedPermissions).toStrictEqual([]);
});
it('should set allowed to true when all permissions are granted', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [true, true])),
);
}),
);
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
wrapper,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.allowed).toBe(true);
expect(result.current.deniedPermissions).toStrictEqual([]);
});
it('should collect all denied permissions when multiple are denied', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [false, false])),
);
}),
);
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
wrapper,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.allowed).toBe(false);
expect(result.current.deniedPermissions).toStrictEqual([
permission1,
permission2,
]);
});
it('should not fetch when enabled is false', async () => {
let requestCount = 0;
const permission = buildPermission('read', 'role:*');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
requestCount += 1;
const payload = await req.json();
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
}),
);
const { result } = renderHook(
() => useAuthZ([permission], { enabled: false }),
{ wrapper },
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(requestCount).toBe(0);
expect(result.current.allowed).toBe(false);
expect(result.current.permissions).toStrictEqual({});
});
it('should refetch when permissions array changes', async () => {
@@ -562,120 +474,3 @@ describe('useAuthZ', () => {
expect(result2.current.permissions).not.toHaveProperty(permission1);
});
});
describe('useAuthZ cache invalidation', () => {
it('should re-render with updated data when query is invalidated', async () => {
const permission = buildPermission('read', 'role:*');
let requestCount = 0;
let shouldGrant = true;
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
requestCount++;
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [shouldGrant])),
);
}),
);
const { result } = renderHook(
() => {
const queryClient = useQueryClient();
const authz = useAuthZ([permission]);
return { authz, queryClient };
},
{ wrapper },
);
await waitFor(() => {
expect(result.current.authz.isLoading).toBe(false);
});
expect(requestCount).toBe(1);
expect(result.current.authz.allowed).toBe(true);
expect(result.current.authz.permissions).toStrictEqual({
[permission]: { isGranted: true },
});
// Change server response and reset query (forces refetch)
shouldGrant = false;
await act(async () => {
await result.current.queryClient.resetQueries(['authz', permission]);
});
await waitFor(() => {
expect(result.current.authz.allowed).toBe(false);
});
expect(requestCount).toBe(2);
expect(result.current.authz.permissions).toStrictEqual({
[permission]: { isGranted: false },
});
});
it('should re-render all components using the same permission when invalidated', async () => {
const permission = buildPermission('update', 'role:123');
let requestCount = 0;
let shouldGrant = true;
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
requestCount++;
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [shouldGrant])),
);
}),
);
// Two separate hooks using the same permission
const { result: result1 } = renderHook(
() => {
const queryClient = useQueryClient();
const authz = useAuthZ([permission]);
return { authz, queryClient };
},
{ wrapper },
);
const { result: result2 } = renderHook(() => useAuthZ([permission]), {
wrapper,
});
await waitFor(() => {
expect(result1.current.authz.isLoading).toBe(false);
expect(result2.current.isLoading).toBe(false);
});
// Both should show granted, single batched request
expect(requestCount).toBe(1);
expect(result1.current.authz.allowed).toBe(true);
expect(result2.current.allowed).toBe(true);
// Change server response and reset query (forces refetch)
shouldGrant = false;
await act(async () => {
await result1.current.queryClient.resetQueries(['authz', permission]);
});
// Both hooks should update
await waitFor(() => {
expect(result1.current.authz.allowed).toBe(false);
expect(result2.current.allowed).toBe(false);
});
expect(result1.current.authz.permissions).toStrictEqual({
[permission]: { isGranted: false },
});
expect(result2.current.permissions).toStrictEqual({
[permission]: { isGranted: false },
});
});
});

View File

@@ -1,15 +1,13 @@
import { useCallback, useMemo } from 'react';
import { useQueries } from 'react-query';
import { isAxiosError } from 'axios';
import { authzCheck } from 'api/generated/services/authz';
import type {
CoretypesObjectDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { IS_DEV, MODE } from 'lib/env';
import { AUTHZ_CACHE_TIME, SINGLE_FLIGHT_WAIT_TIME_MS } from './constants';
import type {
import {
AuthZCheckResponse,
BrandedPermission,
UseAuthZOptions,
@@ -19,59 +17,6 @@ import {
gettableTransactionToPermission,
permissionToTransactionDto,
} from './utils';
import { OverrideState } from '../../devtools/types';
let devStoreRef:
| typeof import('../../devtools/useAuthZDevStore').useAuthZDevStore
| null = null;
if (IS_DEV) {
void import('../../devtools/useAuthZDevStore').then((mod) => {
devStoreRef = mod.useAuthZDevStore;
return mod;
});
}
const DEV_DELAY_MS = 2000;
function getDevOverride(permission: BrandedPermission): OverrideState | null {
if (!IS_DEV || !devStoreRef) {
return null;
}
return devStoreRef.getState().overrides[permission] ?? null;
}
async function applyDevOverrideToQuery(
permission: BrandedPermission,
fetchFn: () => Promise<AuthZCheckResponse>,
): Promise<AuthZCheckResponse> {
const override = getDevOverride(permission);
if (override === OverrideState.Error) {
throw new Error(`[AuthZ DevTools] Simulated error for: ${permission}`);
}
if (override === OverrideState.Delay) {
await new Promise((resolve) => setTimeout(resolve, DEV_DELAY_MS));
}
const response = await fetchFn();
if (IS_DEV && devStoreRef) {
const apiValue = response[permission]?.isGranted ?? false;
devStoreRef.getState().registerObserved(permission, apiValue);
}
if (override === OverrideState.Granted) {
return { [permission]: { isGranted: true } };
}
if (override === OverrideState.Denied) {
return { [permission]: { isGranted: false } };
}
return response;
}
let ctx: Promise<AuthZCheckResponse> | null;
let pendingPermissions: BrandedPermission[] = [];
@@ -82,11 +27,10 @@ function dispatchPermission(
pendingPermissions.push(permission);
if (!ctx) {
let promiseResolve: (v: AuthZCheckResponse) => void,
promiseReject: (reason?: unknown) => void;
ctx = new Promise<AuthZCheckResponse>((resolve, reject) => {
promiseResolve = resolve;
promiseReject = reject;
let resolve: (v: AuthZCheckResponse) => void, reject: (reason?: any) => void;
ctx = new Promise<AuthZCheckResponse>((r, re) => {
resolve = r;
reject = re;
});
setTimeout(() => {
@@ -94,9 +38,7 @@ function dispatchPermission(
pendingPermissions = [];
ctx = null;
fetchManyPermissions(copiedPermissions)
.then(promiseResolve)
.catch(promiseReject);
fetchManyPermissions(copiedPermissions).then(resolve).catch(reject);
}, SINGLE_FLIGHT_WAIT_TIME_MS);
}
@@ -143,50 +85,19 @@ export function useAuthZ(
return {
queryKey: ['authz', permission],
cacheTime: AUTHZ_CACHE_TIME,
staleTime: AUTHZ_CACHE_TIME,
// Keep errored state in cache instead of refetching when new observers subscribe
retryOnMount: false,
// Only override retry in non-test mode to avoid interfering with test-utils QueryClient defaults
...(MODE !== 'test' && {
retry: (failureCount: number, error: unknown): boolean => {
// Don't retry simulated dev errors - they will always fail
if (
error instanceof Error &&
error.message.includes('[AuthZ DevTools]')
) {
return false;
}
// Don't retry server errors (5xx) - they won't recover
if (
isAxiosError(error) &&
error.response?.status &&
error.response.status >= 500
) {
return false;
}
return failureCount < 3;
},
}),
refetchOnMount: false,
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
queryFn: async (): Promise<AuthZCheckResponse> => {
const fetchFn = async (): Promise<AuthZCheckResponse> => {
const response = await dispatchPermission(permission);
return {
[permission]: {
isGranted: response[permission].isGranted,
},
};
const response = await dispatchPermission(permission);
return {
[permission]: {
isGranted: response[permission].isGranted,
},
};
if (IS_DEV) {
return applyDevOverrideToQuery(permission, fetchFn);
}
return fetchFn();
},
};
}),
@@ -196,7 +107,6 @@ export function useAuthZ(
() => queryResults.some((q) => q.isLoading),
[queryResults],
);
const isFetching = useMemo(
() => queryResults.some((q) => q.isFetching),
[queryResults],
@@ -229,31 +139,15 @@ export function useAuthZ(
const refetchPermissions = useCallback(() => {
for (const query of queryResults) {
void query.refetch();
query.refetch();
}
}, [queryResults]);
const allowed = useMemo(() => {
if (isLoading || error || !data) {
return false;
}
return permissions.every((check) => data[check]?.isGranted === true);
}, [permissions, data, isLoading, error]);
const deniedPermissions = useMemo(() => {
if (!data) {
return [];
}
return permissions.filter((check) => data[check]?.isGranted !== true);
}, [permissions, data]);
return {
isLoading,
isFetching,
error,
permissions: data ?? null,
allowed,
deniedPermissions,
refetchPermissions,
};
}

View File

@@ -168,8 +168,6 @@ export function mockUseAuthZGrantAll(
permissions: Object.fromEntries(
permissions.map((p) => [p, { isGranted: true }]),
) as UseAuthZResult['permissions'],
allowed: true,
deniedPermissions: [],
refetchPermissions: jest.fn(),
};
}
@@ -185,8 +183,6 @@ export function mockUseAuthZDenyAll(
permissions: Object.fromEntries(
permissions.map((p) => [p, { isGranted: false }]),
) as UseAuthZResult['permissions'],
allowed: false,
deniedPermissions: permissions,
refetchPermissions: jest.fn(),
};
}
@@ -197,23 +193,16 @@ export function mockUseAuthZGrantByPrefix(
permissions: BrandedPermission[],
options?: UseAuthZOptions,
) => UseAuthZResult {
return (permissions, _options) => {
const denied = permissions.filter(
(p) => !prefixes.some((prefix) => p.startsWith(prefix)),
);
return {
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => [
p,
{ isGranted: prefixes.some((prefix) => p.startsWith(prefix)) },
]),
) as UseAuthZResult['permissions'],
allowed: denied.length === 0,
deniedPermissions: denied,
refetchPermissions: jest.fn(),
};
};
return (permissions, _options) => ({
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => [
p,
{ isGranted: prefixes.some((prefix) => p.startsWith(prefix)) },
]),
) as UseAuthZResult['permissions'],
refetchPermissions: jest.fn(),
});
}

View File

@@ -1,3 +0,0 @@
export const IS_DEV = import.meta.env.DEV;
export const IS_PROD = import.meta.env.PROD;
export const MODE = import.meta.env.MODE;

View File

@@ -26,8 +26,8 @@ export default function AIAssistantPage(): JSX.Element {
// Skip the mount-time Opened fire when the user expanded an already-open
// drawer/modal — that surface already emitted Opened with the right source.
// Router state (vs a module flag) survives page remounts and aborted
// navigations.
// Router state (vs a module flag) survives StrictMode double-mount and
// aborted navigations.
const fromInApp = location.state?.fromInApp === true;
useEffect(() => {
if (fromInApp) {
@@ -52,34 +52,18 @@ export default function AIAssistantPage(): JSX.Element {
(s) => s.startNewConversation,
);
// Keep refs so the effect can read the latest store state without re-firing
// when it mutates the store mid-effect (it only depends on the URL param).
// Keep a ref so the effect can read latest conversations without re-firing
// when startNewConversation mutates the store mid-effect.
const conversationsRef = useRef(conversations);
conversationsRef.current = conversations;
const activeConversationIdRef = useRef(activeConversationId);
activeConversationIdRef.current = activeConversationId;
useEffect(() => {
// URL points at a known conversation → just activate it.
if (conversationId && conversationsRef.current[conversationId]) {
if (conversationsRef.current[conversationId]) {
setActiveConversation(conversationId);
return;
} else {
const newId = startNewConversation();
history.replace(ROUTES.AI_ASSISTANT.replace(':conversationId', newId));
}
// The URL has no usable conversation id (bare `/ai-assistant`, or a stale
// param). Prefer resuming the active conversation — including the
// rehydrating placeholder for the persisted thread — over minting a new
// one. This is what stops a throwaway blank chat from flashing as a
// second thread during load, and stops a duplicate when the page
// remounts during startup route churn (the active id is already set, so
// we resume instead of create). Starting fresh is the last resort, only
// when there is genuinely nothing to resume.
const activeId = activeConversationIdRef.current;
const resumeId =
activeId && conversationsRef.current[activeId]
? activeId
: startNewConversation();
history.replace(ROUTES.AI_ASSISTANT.replace(':conversationId', resumeId));
// Only re-run when the URL param changes, not when conversations mutates.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId]);

View File

@@ -1,181 +0,0 @@
import { MemoryRouter, Route } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { render } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('container/AIAssistant/ConversationView', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="conversation-view" />,
}));
jest.mock('container/AIAssistant/components/ConversationsList', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="conversations-list" />,
}));
jest.mock('components/Noz/Noz', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="noz" />,
}));
jest.mock('container/AIAssistant/hooks/useAIAssistantAnalyticsContext', () => ({
normalizePage: (page: string): string => page,
useAIAssistantAnalyticsContext: (): unknown => ({ mode: 'page' }),
}));
// eslint-disable-next-line import/first
import AIAssistantPage from '../AIAssistantPage';
function renderAt(entry: string): { unmount: () => void } {
return render(
<MemoryRouter initialEntries={[entry]}>
<Route
exact
path={[ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT]}
component={AIAssistantPage}
/>
</MemoryRouter>,
);
}
function renderAtBase(): { unmount: () => void } {
return renderAt(ROUTES.AI_ASSISTANT_BASE);
}
function conversationCount(): number {
return Object.keys(useAIAssistantStore.getState().conversations).length;
}
function conversationIds(): string[] {
return Object.keys(useAIAssistantStore.getState().conversations);
}
function activeId(): string | null {
return useAIAssistantStore.getState().activeConversationId;
}
describe('AIAssistantPage', () => {
beforeEach(() => {
useAIAssistantStore.setState({
conversations: {},
streams: {},
activeConversationId: null,
});
});
it('opens exactly one conversation when navigating to /ai-assistant', () => {
const { unmount } = renderAtBase();
expect(conversationCount()).toBe(1);
unmount();
});
it('does not stack a second conversation when the page remounts at the bare URL (route churn)', () => {
// First mount at `/ai-assistant` creates one blank conversation and
// redirects to `/ai-assistant/:id`.
const { unmount } = renderAtBase();
expect(conversationCount()).toBe(1);
const firstId = conversationIds()[0];
// Startup route-list churn unmounts and remounts the page while the URL
// is momentarily back at the bare `/ai-assistant`. This previously
// created a second blank conversation — now it reuses the first.
unmount();
const { unmount: unmount2 } = renderAtBase();
expect(conversationCount()).toBe(1);
// The surviving conversation is the original one, resumed — not a fresh mint.
expect(conversationIds()).toStrictEqual([firstId]);
expect(activeId()).toBe(firstId);
unmount2();
});
it('activates the conversation named in the URL without creating a new one', () => {
useAIAssistantStore.setState({
conversations: {
existing: {
id: 'existing',
messages: [],
createdAt: 1,
updatedAt: 1,
},
},
streams: {},
activeConversationId: null,
});
const { unmount } = renderAt(
ROUTES.AI_ASSISTANT.replace(':conversationId', 'existing'),
);
expect(conversationCount()).toBe(1);
expect(activeId()).toBe('existing');
unmount();
});
it('resumes the active conversation on /ai-assistant/new instead of minting a new one', () => {
// The sidenav only routes to `/ai-assistant/new` as a fallback, but if an
// active conversation exists the page must resume it rather than spawn a
// throwaway blank thread for the unknown "new" param.
useAIAssistantStore.setState({
conversations: {
active: {
id: 'active',
messages: [],
createdAt: 1,
updatedAt: 1,
},
},
streams: {},
activeConversationId: 'active',
});
const { unmount } = renderAt(
ROUTES.AI_ASSISTANT.replace(':conversationId', 'new'),
);
expect(conversationCount()).toBe(1);
expect(conversationIds()).toStrictEqual(['active']);
expect(activeId()).toBe('active');
unmount();
});
it('resumes the persisted (hydrating) conversation during load instead of creating a second', () => {
// Simulates `onRehydrateStorage` priming the persisted active
// conversation as a hydrating placeholder before `fetchThreads` resolves.
useAIAssistantStore.setState({
conversations: {
persisted: {
id: 'persisted',
messages: [],
createdAt: 1,
updatedAt: 1,
isHydrating: true,
},
},
streams: {},
activeConversationId: 'persisted',
});
const { unmount } = renderAtBase();
// Opening the bare URL must resume the persisted conversation, not mint a
// throwaway blank alongside it (which flashed as a 2nd thread during load).
expect(conversationCount()).toBe(1);
expect(
Object.keys(useAIAssistantStore.getState().conversations),
).toStrictEqual(['persisted']);
unmount();
});
});

View File

@@ -32,22 +32,6 @@
cursor: help;
}
.descriptionTooltip {
display: block;
overflow-wrap: anywhere;
/* Preserve author-entered line breaks in the description. */
white-space: pre-wrap;
a {
color: var(--accent-primary);
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
.publicLink {
display: inline-flex;
align-items: center;

View File

@@ -14,7 +14,6 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import { linkifyText } from 'utils/linkifyText';
import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
@@ -144,14 +143,7 @@ function DashboardInfo({
)}
{hasDescription && (
<TooltipSimple
side="bottom"
title={
<span className={styles.descriptionTooltip}>
{linkifyText(description)}
</span>
}
>
<TooltipSimple title={description} disableHoverableContent>
<SolidInfoCircle
className={styles.descriptionIcon}
size={14}

View File

@@ -48,13 +48,6 @@
gap: 12px;
}
.footerStatus {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.validation {
min-width: 0;
overflow: hidden;
@@ -64,32 +57,6 @@
white-space: nowrap;
}
.danglingWarning {
display: flex;
min-width: 0;
align-items: center;
gap: 4px;
color: var(--bg-amber-400);
font-size: 12px;
}
.warningIcon {
flex: none;
}
.warningText {
overflow: hidden;
color: var(--bg-amber-400);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
// Above the JSON drawer (antd Drawer sits at ~1100) so the id list is visible.
.warningTooltip {
z-index: 1101;
}
.validationValid {
color: var(--bg-forest-400);
}

View File

@@ -1,8 +1,6 @@
import { KeyboardEvent, useCallback } from 'react';
import MEditor from '@monaco-editor/react';
import { TriangleAlert } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { Drawer } from 'antd';
@@ -28,18 +26,8 @@ function JsonEditorDrawer({
}: JsonEditorDrawerProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const {
draft,
setDraft,
validity,
isDirty,
isSaving,
danglingPanelIds,
missingPanelRefs,
format,
reset,
apply,
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const onCopy = useCallback((): void => {
copyToClipboard(draft);
@@ -60,7 +48,6 @@ function JsonEditorDrawer({
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
event.stopPropagation();
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void apply();
@@ -73,19 +60,6 @@ function JsonEditorDrawer({
const validationText = validity.valid
? `Valid JSON · ${validity.lineCount} lines`
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
const plural = (n: number): string => (n === 1 ? '' : 's');
const danglingWarning =
danglingPanelIds.length > 0
? `${danglingPanelIds.length} panel${plural(
danglingPanelIds.length,
)} not present in layout — they won't be shown after saving.`
: null;
const missingRefWarning =
missingPanelRefs.length > 0
? `${missingPanelRefs.length} layout item${plural(
missingPanelRefs.length,
)} ${missingPanelRefs.length === 1 ? 'references' : 'reference'} a panel that no longer exists.`
: null;
return (
<Drawer
@@ -97,49 +71,15 @@ function JsonEditorDrawer({
rootClassName={styles.root}
footer={
<div className={styles.footer}>
<div className={styles.footerStatus}>
<Typography.Text
className={cx(styles.validation, {
[styles.validationValid]: validity.valid,
[styles.validationInvalid]: !validity.valid,
})}
data-testid="json-editor-validation"
>
{validationText}
</Typography.Text>
{danglingWarning && (
<TooltipSimple
title={danglingPanelIds.join(', ')}
tooltipContentProps={{ className: styles.warningTooltip }}
>
<span
className={styles.danglingWarning}
data-testid="json-editor-dangling-warning"
>
<TriangleAlert size={12} className={styles.warningIcon} />
<Typography.Text className={styles.warningText}>
{danglingWarning}
</Typography.Text>
</span>
</TooltipSimple>
)}
{missingRefWarning && (
<TooltipSimple
title={missingPanelRefs.join(', ')}
tooltipContentProps={{ className: styles.warningTooltip }}
>
<span
className={styles.danglingWarning}
data-testid="json-editor-missing-ref-warning"
>
<TriangleAlert size={12} className={styles.warningIcon} />
<Typography.Text className={styles.warningText}>
{missingRefWarning}
</Typography.Text>
</span>
</TooltipSimple>
)}
</div>
<Typography.Text
className={cx(styles.validation, {
[styles.validationValid]: validity.valid,
[styles.validationInvalid]: !validity.valid,
})}
data-testid="json-editor-validation"
>
{validationText}
</Typography.Text>
<div className={styles.footerActions}>
<Button
variant="outlined"

View File

@@ -1,5 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import JsonEditorDrawer from '../JsonEditorDrawer';
@@ -49,13 +48,11 @@ function hookValue(
validity: { valid: true, lineCount: 3 },
isDirty: true,
isSaving: false,
danglingPanelIds: [],
missingPanelRefs: [],
format: jest.fn(),
reset: jest.fn(),
apply: jest.fn().mockResolvedValue(undefined),
...overrides,
} as ReturnType<typeof useJsonEditor>;
};
}
describe('JsonEditorDrawer', () => {
@@ -84,42 +81,6 @@ describe('JsonEditorDrawer', () => {
);
});
it('warns about dangling panels, and hides the warning when there are none', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({ danglingPanelIds: ['p1', 'p2'] }),
);
const { rerender } = render(
<TooltipProvider>
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
</TooltipProvider>,
);
expect(screen.getByTestId('json-editor-dangling-warning')).toHaveTextContent(
'2 panels not present in layout',
);
mockUseJsonEditor.mockReturnValue(hookValue({ danglingPanelIds: [] }));
rerender(
<TooltipProvider>
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
</TooltipProvider>,
);
expect(
screen.queryByTestId('json-editor-dangling-warning'),
).not.toBeInTheDocument();
});
it('warns about layout refs to missing panels', () => {
mockUseJsonEditor.mockReturnValue(hookValue({ missingPanelRefs: ['ghost'] }));
render(
<TooltipProvider>
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
</TooltipProvider>,
);
expect(
screen.getByTestId('json-editor-missing-ref-warning'),
).toHaveTextContent('1 layout item references a panel that no longer exists');
});
it('shows the error line and message when invalid', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({

View File

@@ -1,69 +0,0 @@
import type { DashboardtypesDashboardSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { findPanelLayoutIssues } from '../danglingPanels';
const grid = (refs: string[]): unknown => ({
kind: 'Grid',
spec: { items: refs.map((r) => ({ content: { $ref: r } })) },
});
// Cast a loose fixture to the spec type — the helper is defensive against the
// untrusted, hand-edited JSON it runs on.
const spec = (value: unknown): DashboardtypesDashboardSpecDTO =>
value as DashboardtypesDashboardSpecDTO;
describe('findPanelLayoutIssues', () => {
it('flags nothing when panels and layouts agree', () => {
expect(
findPanelLayoutIssues(
spec({
panels: { a: {}, b: {} },
layouts: [grid(['#/spec/panels/a', '#/spec/panels/b'])],
}),
),
).toStrictEqual({ danglingPanelIds: [], missingPanelRefs: [] });
});
it('lists panels placed in no layout as dangling', () => {
const result = findPanelLayoutIssues(
spec({
panels: { a: {}, b: {}, c: {} },
layouts: [grid(['#/spec/panels/a'])],
}),
);
expect(result.danglingPanelIds.sort()).toStrictEqual(['b', 'c']);
expect(result.missingPanelRefs).toStrictEqual([]);
});
it('treats a removed/empty layout as orphaning every panel', () => {
expect(
findPanelLayoutIssues(
spec({ panels: { a: {}, b: {} }, layouts: [] }),
).danglingPanelIds.sort(),
).toStrictEqual(['a', 'b']);
});
it('lists layout refs to a panel that no longer exists as missing', () => {
const result = findPanelLayoutIssues(
spec({
panels: { a: {} },
layouts: [grid(['#/spec/panels/a', '#/spec/panels/ghost'])],
}),
);
expect(result.danglingPanelIds).toStrictEqual([]);
expect(result.missingPanelRefs).toStrictEqual(['ghost']);
});
it('handles empty / malformed specs', () => {
expect(
findPanelLayoutIssues(spec({ panels: {}, layouts: [] })),
).toStrictEqual({
danglingPanelIds: [],
missingPanelRefs: [],
});
expect(findPanelLayoutIssues(undefined)).toStrictEqual({
danglingPanelIds: [],
missingPanelRefs: [],
});
});
});

View File

@@ -203,47 +203,4 @@ describe('useJsonEditor', () => {
rerender({ isOpen: true });
expect(result.current.draft).toBe(serialized);
});
it('reports panels not placed in any layout as dangling', () => {
const withDangling = {
...dashboard,
spec: { ...dashboard.spec, panels: { p1: {} }, layouts: [] },
} as unknown as DashboardtypesGettableDashboardV2DTO;
const { result } = renderHook(() =>
useJsonEditor({
dashboard: withDangling,
isOpen: true,
onApplied: jest.fn(),
}),
);
expect(result.current.danglingPanelIds).toStrictEqual(['p1']);
expect(result.current.missingPanelRefs).toStrictEqual([]);
});
it('reports layout refs to panels that no longer exist as missing', () => {
const withMissing = {
...dashboard,
spec: {
...dashboard.spec,
panels: {},
layouts: [
{
kind: 'Grid',
spec: { items: [{ content: { $ref: '#/spec/panels/ghost' } }] },
},
],
},
} as unknown as DashboardtypesGettableDashboardV2DTO;
const { result } = renderHook(() =>
useJsonEditor({
dashboard: withMissing,
isOpen: true,
onApplied: jest.fn(),
}),
);
expect(result.current.missingPanelRefs).toStrictEqual(['ghost']);
expect(result.current.danglingPanelIds).toStrictEqual([]);
});
});

View File

@@ -1,47 +0,0 @@
import type {
DashboardtypesDashboardSpecDTO,
DashboardtypesLayoutDTO,
} from 'api/generated/services/sigNoz.schemas';
import { extractPanelIdFromRef } from '../../utils';
export interface PanelLayoutIssues {
// Panels defined in `spec.panels` that no layout places — they render nowhere.
danglingPanelIds: string[];
// Panel ids a layout item references that no longer exist in `spec.panels`.
missingPanelRefs: string[];
}
const referencedPanelIds = (
layouts: DashboardtypesLayoutDTO[],
): Set<string> => {
const referenced = new Set<string>();
layouts.forEach((layout) => {
if (layout?.kind !== 'Grid') {
return;
}
(layout.spec?.items ?? []).forEach((item) => {
const id = extractPanelIdFromRef(item?.content?.$ref);
if (id) {
referenced.add(id);
}
});
});
return referenced;
};
// The two ways a hand-edited spec can desync panels and layouts: a panel with no
// layout slot (renders nowhere), or a layout slot pointing at a panel that was
// removed (broken reference). Guarded for untrusted, user-edited JSON.
export function findPanelLayoutIssues(
spec: DashboardtypesDashboardSpecDTO | undefined,
): PanelLayoutIssues {
const panels = spec?.panels ?? {};
const panelIds = Object.keys(panels);
const referenced = referencedPanelIds(spec?.layouts ?? []);
return {
danglingPanelIds: panelIds.filter((id) => !referenced.has(id)),
missingPanelRefs: [...referenced].filter((id) => !(id in panels)),
};
}

View File

@@ -1,15 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { updateDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesDashboardSpecDTO,
DashboardtypesGettableDashboardV2DTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { toAPIError } from 'utils/errorUtils';
import APIError from 'types/api/error';
import { dashboardToUpdatable } from './dashboardToUpdatable';
import { findPanelLayoutIssues } from './danglingPanels';
import { useDashboardStore } from '../../store/useDashboardStore';
export interface JsonValidity {
@@ -32,10 +28,6 @@ interface Result {
validity: JsonValidity;
isDirty: boolean;
isSaving: boolean;
// Panel ids in the draft's `spec.panels` referenced by no layout — orphaned.
danglingPanelIds: string[];
// Panel ids a layout references that are missing from the draft's `spec.panels`.
missingPanelRefs: string[];
format: () => void;
reset: () => void;
apply: () => Promise<void>;
@@ -117,23 +109,6 @@ export function useJsonEditor({
const isDirty = draft !== appliedText;
const { danglingPanelIds, missingPanelRefs } = useMemo<{
danglingPanelIds: string[];
missingPanelRefs: string[];
}>(() => {
if (!validity.valid) {
return { danglingPanelIds: [], missingPanelRefs: [] };
}
try {
const parsed = JSON.parse(draft) as {
spec?: DashboardtypesDashboardSpecDTO;
};
return findPanelLayoutIssues(parsed.spec);
} catch {
return { danglingPanelIds: [], missingPanelRefs: [] };
}
}, [draft, validity.valid]);
const format = useCallback((): void => {
try {
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
@@ -163,7 +138,7 @@ export function useJsonEditor({
refetch();
onApplied();
} catch (error) {
showErrorModal(toAPIError(error as Parameters<typeof toAPIError>[0]));
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
@@ -184,8 +159,6 @@ export function useJsonEditor({
validity,
isDirty,
isSaving,
danglingPanelIds,
missingPanelRefs,
format,
reset,
apply,

View File

@@ -63,13 +63,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const {
isPickerOpen,
openPicker,
closePicker,
createPanel,
targetLayoutIndex,
} = useCreatePanel();
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -188,7 +183,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
defaultLayoutIndex={targetLayoutIndex}
/>
</section>
);

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
@@ -55,32 +54,20 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
const buildPatch = useCallback((): DashboardtypesJSONPatchOperationDTO[] => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
const op = (
operation: DashboardtypesJSONPatchOperationDTO['op'],
path: string,
value: unknown,
): DashboardtypesJSONPatchOperationDTO => ({ op: operation, path, value });
const replace = (
path: string,
value: unknown,
): DashboardtypesJSONPatchOperationDTO =>
op(DashboardtypesPatchOpDTO.replace, path, value);
): DashboardtypesJSONPatchOperationDTO => ({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path,
value,
});
if (updatedTitle !== title && updatedTitle !== '') {
ops.push(replace('/spec/display/name', updatedTitle));
}
if (updatedDescription !== description) {
// `replace` fails when the description doesn't exist yet, so add it when
// the current one is empty (`add` creates or replaces the member).
ops.push(
op(
description
? DashboardtypesPatchOpDTO.replace
: DashboardtypesPatchOpDTO.add,
'/spec/display/description',
updatedDescription,
),
);
ops.push(replace('/spec/display/description', updatedDescription));
}
if (updatedImage !== image) {
ops.push(replace('/image', updatedImage));

View File

@@ -11,18 +11,14 @@ import styles from './Header.module.scss';
interface HeaderProps {
isDirty: boolean;
isSaving: boolean;
showSwitchToView?: boolean;
onSave: () => void;
onSwitchToView?: () => void;
onClose: () => void;
}
function Header({
isDirty,
isSaving,
showSwitchToView = false,
onSave,
onSwitchToView,
onClose,
}: HeaderProps): JSX.Element {
const discard = useConfirmableAction(
@@ -53,16 +49,6 @@ function Header({
<Typography.Text>Configure panel</Typography.Text>
</div>
<div className={styles.actions}>
{showSwitchToView && (
<Button
variant="outlined"
color="secondary"
data-testid="panel-editor-v2-switch-to-view"
onClick={onSwitchToView}
>
Switch to View Mode
</Button>
)}
<Button
variant="solid"
color="primary"

View File

@@ -5,7 +5,6 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
@@ -43,10 +42,6 @@ interface PreviewPaneProps {
dashboardPreference?: DashboardPreference;
/** Close the standalone View modal — forwarded to the time-series/bar graph manager. */
onCloseStandaloneView?: () => void;
/** Opens the drill-down context menu; only the View modal wires it (the editor preview omits it). */
onClick?: AnyPanelInteractionProps['onClick'];
/** Arms the drill-down click on interactive renderers — the View modal enables it, the editor doesn't. */
enableDrillDown?: boolean;
}
/**
@@ -69,8 +64,6 @@ function PreviewPane({
hideHeader = false,
dashboardPreference,
onCloseStandaloneView,
onClick,
enableDrillDown,
}: PreviewPaneProps): JSX.Element {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const queryType = getPanelQueryType(panel);
@@ -126,8 +119,6 @@ function PreviewPane({
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onCloseStandaloneView={onCloseStandaloneView}
onClick={onClick}
enableDrillDown={enableDrillDown}
/>
</div>
</div>

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