mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-26 10:00:28 +01:00
Compare commits
35 Commits
ns/scope
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e9b935c5 | ||
|
|
d4849984da | ||
|
|
1e841949ab | ||
|
|
478b4b3cd0 | ||
|
|
811c7bba90 | ||
|
|
5a5c10b333 | ||
|
|
ed8c8046f4 | ||
|
|
ec88a1560a | ||
|
|
27b73064c2 | ||
|
|
55569c5986 | ||
|
|
9d9d6c9b93 | ||
|
|
2fe2744340 | ||
|
|
c512fbafc9 | ||
|
|
aabf1fb18c | ||
|
|
757da5cd57 | ||
|
|
b4fc02f18e | ||
|
|
9295c384a8 | ||
|
|
9ea086e6d8 | ||
|
|
a5c8f413ff | ||
|
|
46c7f2c285 | ||
|
|
c6330182f2 | ||
|
|
afe643647a | ||
|
|
853397a79e | ||
|
|
bd526df11d | ||
|
|
8ac07d3d37 | ||
|
|
9bab8e0ae2 | ||
|
|
8040f222ad | ||
|
|
a45212cb79 | ||
|
|
a609a4044c | ||
|
|
f78d98ea71 | ||
|
|
f60e5039be | ||
|
|
a483ef81a4 | ||
|
|
b9c107a851 | ||
|
|
5f6cc4c297 | ||
|
|
69e4c3c6f3 |
17
.github/workflows/goci.yaml
vendored
17
.github/workflows/goci.yaml
vendored
@@ -140,3 +140,20 @@ jobs:
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config web-settings
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in web settings schema. Run go run cmd/enterprise/*.go generate config web-settings locally and commit."; exit 1)
|
||||
transaction-groups:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: generate-transaction-groups
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config transaction-groups
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in transaction groups schema. Run go run cmd/enterprise/*.go generate config transaction-groups locally and commit."; exit 1)
|
||||
|
||||
@@ -6,12 +6,15 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
const webSettingsSchemaPath = "docs/config/web-settings.json"
|
||||
const webSettingsSchemaPath = "frontend/src/schemas/generated/webSettings.schema.json"
|
||||
|
||||
const transactionGroupsSchemaPath = "frontend/src/schemas/generated/transactionGroups.schema.json"
|
||||
|
||||
func registerGenerateConfig(parentCmd *cobra.Command) {
|
||||
configCmd := &cobra.Command{
|
||||
@@ -27,6 +30,14 @@ func registerGenerateConfig(parentCmd *cobra.Command) {
|
||||
},
|
||||
})
|
||||
|
||||
configCmd.AddCommand(&cobra.Command{
|
||||
Use: "transaction-groups",
|
||||
Short: "Generate JSON Schema for transaction groups",
|
||||
RunE: func(currCmd *cobra.Command, args []string) error {
|
||||
return generateTransactionGroups()
|
||||
},
|
||||
})
|
||||
|
||||
parentCmd.AddCommand(configCmd)
|
||||
}
|
||||
|
||||
@@ -52,6 +63,7 @@ func generateWebSettings() error {
|
||||
return err
|
||||
}
|
||||
|
||||
schema.WithTitle("WebSettings")
|
||||
data, err := json.MarshalIndent(schema, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -59,3 +71,31 @@ func generateWebSettings() error {
|
||||
|
||||
return os.WriteFile(webSettingsSchemaPath, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
func generateTransactionGroups() error {
|
||||
falseVal := false
|
||||
noAdditional := jsonschema.SchemaOrBool{TypeBoolean: &falseVal}
|
||||
|
||||
reflector := jsonschema.Reflector{}
|
||||
reflector.DefaultOptions = append(reflector.DefaultOptions,
|
||||
jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (bool, error) {
|
||||
if params.Value.Kind() == reflect.Struct {
|
||||
params.Schema.AdditionalProperties = &noAdditional
|
||||
}
|
||||
return false, nil
|
||||
}),
|
||||
)
|
||||
|
||||
schema, err := reflector.Reflect(authtypes.TransactionGroups{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
schema.WithTitle("TransactionGroups")
|
||||
data, err := json.MarshalIndent(schema, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(transactionGroupsSchemaPath, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
@@ -1,48 +1,76 @@
|
||||
# Migrating from the install script to Foundry
|
||||
# Migrating from the install script and `deploy/` to Foundry
|
||||
|
||||
The install script (`install.sh`) and the bundled Compose and Swarm files
|
||||
under `deploy/` are deprecated in favor of [Foundry][foundry], the supported
|
||||
way to install and manage SigNoz. This guide moves an existing Docker Compose
|
||||
or Docker Swarm deployment to Foundry and reattaches your existing volumes, so
|
||||
your data is preserved.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The install script is now deprecated and will no longer receive updates.
|
||||
> This guide is only for **existing** `install.sh` / `deploy/` deployments.
|
||||
> Setting up SigNoz for the first time? Skip migration and install Foundry
|
||||
> directly: [SigNoz install docs][install-docs].
|
||||
|
||||
This guide walks you through migrating an existing SigNoz deployment running via
|
||||
Docker Compose to [Foundry](https://signoz.io/docs/install/docker/).
|
||||
## How it works
|
||||
|
||||
> [!NOTE]
|
||||
> Setting up SigNoz for the first time? You don't need this guide — follow the [SigNoz installation docs](https://signoz.io/docs/install/) instead.
|
||||
Foundry splits a deployment into two commands:
|
||||
|
||||
## Overview
|
||||
To stay up to date on new installation platforms and patterns, please refer to [Foundry](https://github.com/SigNoz/foundry).
|
||||
- `foundryctl forge` generates the deployment manifests from a `casting.yaml`.
|
||||
It never touches running containers, so it is safe to re-run while you
|
||||
iterate.
|
||||
- `foundryctl cast` applies those manifests: it (re)creates the containers and
|
||||
reuses the volumes you point it at.
|
||||
|
||||
Two `foundryctl` commands are used throughout this guide:
|
||||
- **`forge`** — generates deployment manifests from your `casting.yaml`. It does not touch running containers, so it is safe to re-run while you iterate.
|
||||
- **`cast`** — applies the generated manifests: it creates and starts the containers (and pulls new images).
|
||||
You write one `casting.yaml`, point a few patches at your existing data
|
||||
volumes, then cast. The steps below are the same for Compose and Swarm; they
|
||||
differ only in the casting (step 3) and how you stop the old stack (step 5).
|
||||
|
||||
## Prerequisites
|
||||
- [ ] Install Foundry - `curl -fsSL https://signoz.io/foundry.sh | bash`
|
||||
|
||||
## Migration Steps
|
||||
> [!WARNING]
|
||||
> **Before proceeding, back up both:**
|
||||
> - **Your docker volumes** — these hold your data.
|
||||
> - **Your existing `docker-compose.yaml` (and any config it references)** — keep a copy somewhere safe. The compose manifests are no longer distributed by SigNoz, so this backup is your only way to roll back to your previous setup.
|
||||
- An existing SigNoz deployment from `install.sh` or `deploy/` (Compose or
|
||||
Swarm).
|
||||
- `foundryctl` (installed in step 1).
|
||||
|
||||
1. Make a note of the volume names used by your existing deployment for the following components:
|
||||
- ClickHouse
|
||||
- SigNoz
|
||||
- ZooKeeper
|
||||
## Migrate
|
||||
|
||||
> If you used the docker compose file we provided, the volumes will be `signoz-clickhouse`, `signoz-sqlite`, and `signoz-zookeeper-1`.
|
||||
### 1. Install Foundry
|
||||
|
||||
2. Generate your `casting.yaml`. Based on internal testing, the following casting should generate the manifests that mimic the legacy docker compose setup (compare against your backed-up `docker-compose.yaml`). Once created, run `foundryctl forge -f casting.yaml`.
|
||||
```bash
|
||||
curl -fsSL https://signoz.io/foundry.sh | bash
|
||||
```
|
||||
|
||||
Foundry has a [Docker Compose example](https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose). Please use it as a reference.
|
||||
### 2. Keep your rollback path
|
||||
|
||||
> [!WARNING]
|
||||
> If your deployment had more than 1 shard or replica, you will need to adjust your manifest volumes accordingly.
|
||||
This migration reattaches your existing volumes in place; it does not move or
|
||||
delete your data. The only destructive action is passing `--volumes` / `-v`
|
||||
when you stop the old stack (step 5), so avoid that flag.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `replica` and `shard` macros below are placeholders. Replace them with the values from your existing ClickHouse configuration (check the `macros` section of your current ClickHouse config, e.g. `config.xml`/`metrika.xml`), otherwise the generated manifests will not match your existing data.
|
||||
> Keep a copy of your existing `docker-compose.yaml` / stack file (and any
|
||||
> config it references). SigNoz no longer distributes these files, so this copy
|
||||
> is your only way to roll back.
|
||||
|
||||
### 3. Write your `casting.yaml`
|
||||
|
||||
Use the casting for your deployment. Both reproduce the legacy single-node
|
||||
setup (ClickHouse + ZooKeeper + SQLite) and reattach your existing volumes;
|
||||
they differ only in `spec.deployment.flavor` and the volume-reuse patch
|
||||
(Compose volumes have a `name` to replace; Swarm volumes are bare, so the whole
|
||||
entry is replaced). If your deployment ran more than one shard or replica,
|
||||
adjust the volume patches accordingly. The
|
||||
[Docker Compose example][compose-example] is a useful reference.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `replica` and `shard` macros are placeholders. Replace them with the
|
||||
> values from your existing ClickHouse config (the `macros` section of
|
||||
> `config.xml` / `metrika.xml`), or the generated manifests will not match your
|
||||
> existing data.
|
||||
|
||||
<details>
|
||||
<summary><b>Docker Compose</b> casting.yaml</summary>
|
||||
|
||||
```yaml
|
||||
# casting.yaml
|
||||
apiVersion: v1alpha1
|
||||
kind: Installation
|
||||
metadata:
|
||||
@@ -61,8 +89,8 @@ spec:
|
||||
data:
|
||||
config-0-0.yaml: |
|
||||
macros:
|
||||
replica: "example01-01-1" # replace with your existing ClickHouse replica macro (see legacy configuration files for reference)
|
||||
shard: "01" # replace with your existing ClickHouse shard macro (see legacy configuration files for reference)
|
||||
replica: "example01-01-1" # replace with your replica macro
|
||||
shard: "01" # replace with your shard macro
|
||||
patches:
|
||||
- target: "deployment/compose.yaml"
|
||||
operations:
|
||||
@@ -80,50 +108,163 @@ spec:
|
||||
value: root
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The `user: root` patch on the ZooKeeper service is required so the container can read/write the data in your reused ZooKeeper volume, which was created with `root`-owned files by the legacy compose setup. Without it, ZooKeeper may fail to start with permission errors.
|
||||
</details>
|
||||
|
||||
If you had custom configurations for features like SMTP or additional ingestion processors/receivers, you will need to include those in your casting file via [patches](https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md), [custom configuration](https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files) or [environment variables](https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec) based on your previous configuration.
|
||||
<details>
|
||||
<summary><b>Docker Swarm</b> casting.yaml</summary>
|
||||
|
||||
3. Review your manifests, we suggest executing the following checks on your manifests before proceeding:
|
||||
- [ ] Validate the container images match what your deployment had, Foundry uses `latest` on generation by default.
|
||||
- [ ] If your signoz version was older than latest, please check the [upgrade path](https://signoz.io/docs/operate/upgrade/) first.
|
||||
- [ ] Check the produced manifests in `pours/deployment` match your older configurations. Extra consideration and review needs to be done on `compose.yaml` as this will be the main entry point for your new deployment.
|
||||
- [ ] The configuration files for clickhouse are now in YAML so validate your custom settings are present.
|
||||
```yaml
|
||||
# casting.yaml
|
||||
apiVersion: v1alpha1
|
||||
kind: Installation
|
||||
metadata:
|
||||
name: signoz
|
||||
spec:
|
||||
deployment:
|
||||
flavor: swarm
|
||||
mode: docker
|
||||
metastore:
|
||||
kind: sqlite
|
||||
telemetrykeeper:
|
||||
kind: zookeeper
|
||||
telemetrystore:
|
||||
spec:
|
||||
config:
|
||||
data:
|
||||
config-0-0.yaml: |
|
||||
macros:
|
||||
replica: "example01-01-1" # replace with your replica macro
|
||||
shard: "01" # replace with your shard macro
|
||||
patches:
|
||||
- target: "deployment/compose.yaml"
|
||||
operations:
|
||||
- op: replace
|
||||
path: /volumes/signoz-telemetrykeeper-0-data
|
||||
value:
|
||||
name: signoz-zookeeper-1
|
||||
- op: replace
|
||||
path: /volumes/signoz-telemetrystore-0-0-data
|
||||
value:
|
||||
name: signoz-clickhouse
|
||||
- op: replace
|
||||
path: /volumes/signoz-metastore-sqlite-0-data
|
||||
value:
|
||||
name: signoz-sqlite
|
||||
- op: add
|
||||
path: /services/signoz-telemetrykeeper-zookeeper-0/user
|
||||
value: root
|
||||
```
|
||||
|
||||
4. Execute a `docker compose down`. **Do not** include parameters such as `--volumes` (or `-v`), as it will wipe the volumes we need to maintain and reuse to avoid data loss.
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> This will generate downtime so please plan accordingly.
|
||||
> The `user: root` patch on the ZooKeeper service lets the container read and
|
||||
> write the data in your reused ZooKeeper volume, whose files the legacy setup
|
||||
> created as `root`. Without it, ZooKeeper may fail to start with permission
|
||||
> errors.
|
||||
|
||||
5. Validate the SigNoz containers are down with `docker ps -a`. Multiple containers cannot bind the same volume.
|
||||
If you had custom configuration (SMTP, extra ingestion receivers/processors,
|
||||
or custom ClickHouse settings), carry it over via [patches][patches],
|
||||
[custom config files][custom-config], or [environment variables][env-vars].
|
||||
|
||||
6. Run `foundryctl cast -f casting.yaml`. This will recreate the containers based on the spec. This process will download new container images.
|
||||
### 4. Generate and review the manifests
|
||||
|
||||
```bash
|
||||
foundryctl forge -f casting.yaml
|
||||
```
|
||||
|
||||
Review `pours/deployment/` before deploying:
|
||||
|
||||
- [ ] Container images match your current deployment. Foundry generates with
|
||||
`latest` by default; if your SigNoz version was older than latest, check the
|
||||
[upgrade path][upgrade-path] first.
|
||||
- [ ] The generated manifests match your previous configuration, especially
|
||||
`compose.yaml` (the new entry point for your deployment).
|
||||
- [ ] The ClickHouse config is now YAML rather than XML; confirm your custom
|
||||
settings carried over (see [ClickHouse configuration files][ch-config] for
|
||||
the XML-to-YAML mapping).
|
||||
|
||||
### 5. Stop the old deployment
|
||||
|
||||
Use the command for your deployment. Do **not** pass `--volumes` / `-v`; that
|
||||
would delete the data you are migrating.
|
||||
|
||||
```bash
|
||||
docker compose down # Compose
|
||||
docker stack rm signoz # Swarm
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> When `cast` is run, the migration container will execute its migrations.
|
||||
> This causes downtime, so plan accordingly.
|
||||
|
||||
## Verifying the Migration
|
||||
- SigNoz containers will be up and running.
|
||||
- Log in to the SigNoz UI and verify that data is present.
|
||||
- Signoz will run on localhost:8080
|
||||
- Validate that your data ingestion is receiving data.
|
||||
- Ingesters will receive data on localhost:4317(grpc) and localhost:4318(http)
|
||||
- Review the logs from both ClickHouse and ZooKeeper; no errors should be present.
|
||||
Confirm nothing is still bound to the volumes before continuing:
|
||||
|
||||
## Rolling Back
|
||||
Because step 4 brought the legacy stack down *without* `-v`, your original volumes
|
||||
are untouched and still hold your data. To roll back:
|
||||
```bash
|
||||
docker ps -a
|
||||
```
|
||||
|
||||
- Stop and remove the containers created by Foundry (`docker compose down`, again without `-v`).
|
||||
- Confirm the containers are gone with `docker ps -a` so nothing else is bound to the volumes.
|
||||
- Reapply your original docker compose file (`docker compose up -d`). It will reattach to the
|
||||
existing volumes and restore your prior state.
|
||||
### 6. Deploy with Foundry
|
||||
|
||||
```bash
|
||||
foundryctl cast -f casting.yaml
|
||||
```
|
||||
|
||||
This recreates the containers against your existing volumes and pulls the
|
||||
images. The migration container runs the schema migrations as part of `cast`.
|
||||
|
||||
**Prefer not to use `cast`?** The manifests in `pours/deployment/` are standard
|
||||
Docker artifacts you can apply yourself. Run the command from that directory so
|
||||
the relative config paths resolve:
|
||||
|
||||
```bash
|
||||
cd pours/deployment
|
||||
docker compose up -d # Compose
|
||||
docker stack deploy -c compose.yaml signoz # Swarm
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
- All SigNoz containers are running.
|
||||
- The UI is reachable on `http://localhost:8080`, and OTLP on `4317` (gRPC)
|
||||
and `4318` (HTTP), so already-instrumented apps and saved bookmarks keep
|
||||
working.
|
||||
- Your existing data is present in the UI, and new data is being ingested.
|
||||
- ClickHouse and ZooKeeper logs show no errors.
|
||||
|
||||
## Roll back
|
||||
|
||||
Step 5 left your volumes untouched, so your data is intact. To return to the
|
||||
previous setup:
|
||||
|
||||
1. Bring down the Foundry deployment (`docker compose down` or
|
||||
`docker stack rm signoz`, again without `-v`).
|
||||
2. Confirm the containers are gone with `docker ps -a`.
|
||||
3. Re-apply your backed-up stack: `docker compose up -d` (Compose) or
|
||||
`docker stack deploy -c docker-compose.yaml signoz` (Swarm). It reattaches
|
||||
the same volumes and restores your prior state.
|
||||
|
||||
## Troubleshooting
|
||||
- Please reach out to our community on [Slack](https://signoz.io/slack).
|
||||
|
||||
If the migration runs into trouble, reach out on [Slack][slack] or open a
|
||||
[Foundry issue][foundry-issues].
|
||||
|
||||
## References
|
||||
- [SigNoz Docker installation docs](https://signoz.io/docs/install/docker/)
|
||||
- [SigNoz documentation](https://signoz.io/docs)
|
||||
- [Foundry](https://github.com/SigNoz/foundry)
|
||||
|
||||
- [Foundry][foundry]
|
||||
- [Casting file reference][casting-ref]
|
||||
- [Custom config files][custom-config]
|
||||
- [Patches][patches]
|
||||
- [SigNoz documentation][signoz-docs]
|
||||
|
||||
[foundry]: https://github.com/SigNoz/foundry
|
||||
[install-docs]: https://signoz.io/docs/install/
|
||||
[compose-example]: https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose
|
||||
[patches]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md
|
||||
[custom-config]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files
|
||||
[env-vars]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec
|
||||
[casting-ref]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md
|
||||
[ch-config]: https://clickhouse.com/docs/operations/configuration-files
|
||||
[upgrade-path]: https://signoz.io/docs/operate/upgrade/
|
||||
[slack]: https://signoz.io/slack
|
||||
[foundry-issues]: https://github.com/SigNoz/foundry/issues
|
||||
[signoz-docs]: https://signoz.io/docs
|
||||
|
||||
@@ -651,8 +651,6 @@ components:
|
||||
$ref: '#/components/schemas/AuthtypesTransactionGroups'
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
- transactionGroups
|
||||
type: object
|
||||
AuthtypesPostableRotateToken:
|
||||
properties:
|
||||
@@ -2407,6 +2405,46 @@ components:
|
||||
to_user:
|
||||
type: string
|
||||
type: object
|
||||
CoretypesKind:
|
||||
enum:
|
||||
- anonymous
|
||||
- organization
|
||||
- role
|
||||
- serviceaccount
|
||||
- user
|
||||
- notification-channel
|
||||
- route-policy
|
||||
- apdex-setting
|
||||
- auth-domain
|
||||
- session
|
||||
- cloud-integration
|
||||
- cloud-integration-service
|
||||
- integration
|
||||
- dashboard
|
||||
- public-dashboard
|
||||
- ingestion-key
|
||||
- ingestion-limit
|
||||
- pipeline
|
||||
- user-preference
|
||||
- org-preference
|
||||
- quick-filter
|
||||
- ttl-setting
|
||||
- rule
|
||||
- planned-maintenance
|
||||
- saved-view
|
||||
- trace-funnel
|
||||
- factor-password
|
||||
- factor-api-key
|
||||
- license
|
||||
- subscription
|
||||
- logs
|
||||
- traces
|
||||
- metrics
|
||||
- audit-logs
|
||||
- meter-metrics
|
||||
- logs-field
|
||||
- traces-field
|
||||
type: string
|
||||
CoretypesObject:
|
||||
properties:
|
||||
resource:
|
||||
@@ -2448,7 +2486,7 @@ components:
|
||||
CoretypesResourceRef:
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
$ref: '#/components/schemas/CoretypesKind'
|
||||
type:
|
||||
$ref: '#/components/schemas/CoretypesType'
|
||||
required:
|
||||
@@ -3973,6 +4011,94 @@ components:
|
||||
enabled:
|
||||
type: boolean
|
||||
type: object
|
||||
InframonitoringtypesAssociatedComponent:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesCheckComponentType'
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
type: object
|
||||
InframonitoringtypesAttributesComponentEntry:
|
||||
properties:
|
||||
associatedComponent:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
|
||||
attributes:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- attributes
|
||||
- associatedComponent
|
||||
type: object
|
||||
InframonitoringtypesCheckComponentType:
|
||||
enum:
|
||||
- receiver
|
||||
- processor
|
||||
type: string
|
||||
InframonitoringtypesCheckType:
|
||||
enum:
|
||||
- hosts
|
||||
- processes
|
||||
- pods
|
||||
- nodes
|
||||
- deployments
|
||||
- daemonsets
|
||||
- statefulsets
|
||||
- jobs
|
||||
- namespaces
|
||||
- clusters
|
||||
- volumes
|
||||
type: string
|
||||
InframonitoringtypesChecks:
|
||||
properties:
|
||||
missingDefaultEnabledMetrics:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMissingMetricsComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
missingOptionalMetrics:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMissingMetricsComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
missingRequiredAttributes:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMissingAttributesComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
presentDefaultEnabledMetrics:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMetricsComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
presentOptionalMetrics:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesMetricsComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
presentRequiredAttributes:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAttributesComponentEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
ready:
|
||||
type: boolean
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesCheckType'
|
||||
required:
|
||||
- type
|
||||
- ready
|
||||
- presentDefaultEnabledMetrics
|
||||
- presentOptionalMetrics
|
||||
- presentRequiredAttributes
|
||||
- missingDefaultEnabledMetrics
|
||||
- missingOptionalMetrics
|
||||
- missingRequiredAttributes
|
||||
type: object
|
||||
InframonitoringtypesClusterRecord:
|
||||
properties:
|
||||
clusterCPU:
|
||||
@@ -4016,8 +4142,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesClusterRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4028,7 +4152,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesDaemonSetRecord:
|
||||
@@ -4085,8 +4208,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesDaemonSetRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4097,7 +4218,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesDeploymentRecord:
|
||||
@@ -4154,8 +4274,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesDeploymentRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4166,7 +4284,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesHostFilter:
|
||||
@@ -4232,8 +4349,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4244,7 +4359,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesJobRecord:
|
||||
@@ -4307,8 +4421,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesJobRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4319,9 +4431,59 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesMetricsComponentEntry:
|
||||
properties:
|
||||
associatedComponent:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
|
||||
metrics:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- metrics
|
||||
- associatedComponent
|
||||
type: object
|
||||
InframonitoringtypesMissingAttributesComponentEntry:
|
||||
properties:
|
||||
associatedComponent:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
|
||||
attributes:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
documentationLink:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- attributes
|
||||
- associatedComponent
|
||||
- message
|
||||
- documentationLink
|
||||
type: object
|
||||
InframonitoringtypesMissingMetricsComponentEntry:
|
||||
properties:
|
||||
associatedComponent:
|
||||
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
|
||||
documentationLink:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
metrics:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- metrics
|
||||
- associatedComponent
|
||||
- message
|
||||
- documentationLink
|
||||
type: object
|
||||
InframonitoringtypesNamespaceRecord:
|
||||
properties:
|
||||
meta:
|
||||
@@ -4354,8 +4516,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNamespaceRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4366,7 +4526,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesNodeCondition:
|
||||
@@ -4431,8 +4590,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4443,7 +4600,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesPodCountsByPhase:
|
||||
@@ -4529,8 +4685,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4541,7 +4695,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesPostableClusters:
|
||||
@@ -4804,16 +4957,6 @@ components:
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesRequiredMetricsCheck:
|
||||
properties:
|
||||
missingMetrics:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- missingMetrics
|
||||
type: object
|
||||
InframonitoringtypesResponseType:
|
||||
enum:
|
||||
- list
|
||||
@@ -4873,8 +5016,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesStatefulSetRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4885,7 +5026,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesVolumeRecord:
|
||||
@@ -4933,8 +5073,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesVolumeRecord'
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
@@ -4945,7 +5083,6 @@ components:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
LlmpricingruletypesGettablePricingRules:
|
||||
@@ -14830,6 +14967,72 @@ paths:
|
||||
summary: Health check
|
||||
tags:
|
||||
- health
|
||||
/api/v2/infra_monitoring/checks:
|
||||
get:
|
||||
deprecated: false
|
||||
description: 'Checks whether the metrics and attributes required to power the
|
||||
infra-monitoring section selected by the ''type'' query parameter (hosts,
|
||||
processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces,
|
||||
clusters, volumes) are being received. For each collector receiver or processor
|
||||
that contributes required metrics or attributes, lists what is present and
|
||||
what is missing, with a prebuilt user-facing message and a docs link per missing
|
||||
component. Default-enabled metrics are those expected as soon as the receiver
|
||||
is configured; optional metrics require ''enabled: true'' in receiver config.
|
||||
''ready'' is true only when every missing list is empty.'
|
||||
operationId: GetChecks
|
||||
parameters:
|
||||
- in: query
|
||||
name: type
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesCheckType'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesChecks'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Run Infra Monitoring Setup Checks
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/clusters:
|
||||
post:
|
||||
deprecated: false
|
||||
@@ -14844,10 +15047,10 @@ paths:
|
||||
for custom groupBy keys; in both modes every row aggregates nodes and pods
|
||||
in the group. Supports filtering via a filter expression, custom groupBy,
|
||||
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
|
||||
via offset/limit. Also reports missing required metrics and whether the requested
|
||||
time range falls before the data retention boundary. Numeric metric fields
|
||||
(clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable)
|
||||
return -1 as a sentinel when no data is available for that field.'
|
||||
via offset/limit. Also reports whether the requested time range falls before
|
||||
the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable,
|
||||
clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data
|
||||
is available for that field.'
|
||||
operationId: ListClusters
|
||||
requestBody:
|
||||
content:
|
||||
@@ -14920,11 +15123,11 @@ paths:
|
||||
row aggregates pods owned by daemonsets in the group. Supports filtering via
|
||||
a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit
|
||||
/ memory / memory_request / memory_limit / desired_nodes / current_nodes,
|
||||
and pagination via offset/limit. Also reports missing required metrics and
|
||||
whether the requested time range falls before the data retention boundary.
|
||||
Numeric metric fields (daemonSetCPU, daemonSetCPURequest, daemonSetCPULimit,
|
||||
daemonSetMemory, daemonSetMemoryRequest, daemonSetMemoryLimit, desiredNodes,
|
||||
currentNodes) return -1 as a sentinel when no data is available for that field.'
|
||||
and pagination via offset/limit. Also reports whether the requested time range
|
||||
falls before the data retention boundary. Numeric metric fields (daemonSetCPU,
|
||||
daemonSetCPURequest, daemonSetCPULimit, daemonSetMemory, daemonSetMemoryRequest,
|
||||
daemonSetMemoryLimit, desiredNodes, currentNodes) return -1 as a sentinel
|
||||
when no data is available for that field.'
|
||||
operationId: ListDaemonSets
|
||||
requestBody:
|
||||
content:
|
||||
@@ -14995,11 +15198,11 @@ paths:
|
||||
group. Supports filtering via a filter expression, custom groupBy, ordering
|
||||
by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit
|
||||
/ desired_pods / available_pods, and pagination via offset/limit. Also reports
|
||||
missing required metrics and whether the requested time range falls before
|
||||
the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest,
|
||||
deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit,
|
||||
desiredPods, availablePods) return -1 as a sentinel when no data is available
|
||||
for that field.'
|
||||
whether the requested time range falls before the data retention boundary.
|
||||
Numeric metric fields (deploymentCPU, deploymentCPURequest, deploymentCPULimit,
|
||||
deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods,
|
||||
availablePods) return -1 as a sentinel when no data is available for that
|
||||
field.'
|
||||
operationId: ListDeployments
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15064,10 +15267,9 @@ paths:
|
||||
custom groupBy to aggregate hosts by any attribute, ordering by any of the
|
||||
five metrics, and pagination via offset/limit. The response type is ''list''
|
||||
for the default host.name grouping or ''grouped_list'' for custom groupBy
|
||||
keys. Also reports missing required metrics and whether the requested time
|
||||
range falls before the data retention boundary. Numeric metric fields (cpu,
|
||||
memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available
|
||||
for that field.'
|
||||
keys. Also reports whether the requested time range falls before the data
|
||||
retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage)
|
||||
return -1 as a sentinel when no data is available for that field.'
|
||||
operationId: ListHosts
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15141,11 +15343,11 @@ paths:
|
||||
jobs in the group. Supports filtering via a filter expression, custom groupBy,
|
||||
ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit
|
||||
/ desired_successful_pods / active_pods / failed_pods / successful_pods, and
|
||||
pagination via offset/limit. Also reports missing required metrics and whether
|
||||
the requested time range falls before the data retention boundary. Numeric
|
||||
metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest,
|
||||
jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods)
|
||||
return -1 as a sentinel when no data is available for that field.'
|
||||
pagination via offset/limit. Also reports whether the requested time range
|
||||
falls before the data retention boundary. Numeric metric fields (jobCPU, jobCPURequest,
|
||||
jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods,
|
||||
activePods, failedPods, successfulPods) return -1 as a sentinel when no data
|
||||
is available for that field.'
|
||||
operationId: ListJobs
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15210,10 +15412,10 @@ paths:
|
||||
type is ''list'' for the default k8s.namespace.name grouping or ''grouped_list''
|
||||
for custom groupBy keys; in both modes every row aggregates pods in the group.
|
||||
Supports filtering via a filter expression, custom groupBy, ordering by cpu
|
||||
/ memory, and pagination via offset/limit. Also reports missing required metrics
|
||||
and whether the requested time range falls before the data retention boundary.
|
||||
Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel
|
||||
when no data is available for that field.'
|
||||
/ memory, and pagination via offset/limit. Also reports whether the requested
|
||||
time range falls before the data retention boundary. Numeric metric fields
|
||||
(namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available
|
||||
for that field.'
|
||||
operationId: ListNamespaces
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15281,10 +15483,10 @@ paths:
|
||||
for custom groupBy keys (each row aggregates nodes in the group; condition
|
||||
stays no_data). Supports filtering via a filter expression, custom groupBy,
|
||||
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
|
||||
via offset/limit. Also reports missing required metrics and whether the requested
|
||||
time range falls before the data retention boundary. Numeric metric fields
|
||||
(nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1
|
||||
as a sentinel when no data is available for that field.'
|
||||
via offset/limit. Also reports whether the requested time range falls before
|
||||
the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable,
|
||||
nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is
|
||||
available for that field.'
|
||||
operationId: ListNodes
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15353,11 +15555,10 @@ paths:
|
||||
is one pod with its current phase) or ''grouped_list'' for custom groupBy
|
||||
keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase:
|
||||
{ pending, running, succeeded, failed, unknown } derived from each pod''s
|
||||
latest phase in the window). Also reports missing required metrics and whether
|
||||
the requested time range falls before the data retention boundary. Numeric
|
||||
metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest,
|
||||
podMemoryLimit, podAge) return -1 as a sentinel when no data is available
|
||||
for that field.'
|
||||
latest phase in the window). Also reports whether the requested time range
|
||||
falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest,
|
||||
podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1
|
||||
as a sentinel when no data is available for that field.'
|
||||
operationId: ListPods
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15424,11 +15625,10 @@ paths:
|
||||
usage, inodes, inodes_free, inodes_used), and pagination via offset/limit.
|
||||
The response type is ''list'' for the default k8s.persistentvolumeclaim.name
|
||||
grouping or ''grouped_list'' for custom groupBy keys; in both modes every
|
||||
row aggregates volumes in the group. Also reports missing required metrics
|
||||
and whether the requested time range falls before the data retention boundary.
|
||||
Numeric metric fields (volumeAvailable, volumeCapacity, volumeUsage, volumeInodes,
|
||||
volumeInodesFree, volumeInodesUsed) return -1 as a sentinel when no data is
|
||||
available for that field.'
|
||||
row aggregates volumes in the group. Also reports whether the requested time
|
||||
range falls before the data retention boundary. Numeric metric fields (volumeAvailable,
|
||||
volumeCapacity, volumeUsage, volumeInodes, volumeInodesFree, volumeInodesUsed)
|
||||
return -1 as a sentinel when no data is available for that field.'
|
||||
operationId: ListVolumes
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15499,11 +15699,10 @@ paths:
|
||||
statefulsets in the group. Supports filtering via a filter expression, custom
|
||||
groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request
|
||||
/ memory_limit / desired_pods / current_pods, and pagination via offset/limit.
|
||||
Also reports missing required metrics and whether the requested time range
|
||||
falls before the data retention boundary. Numeric metric fields (statefulSetCPU,
|
||||
statefulSetCPURequest, statefulSetCPULimit, statefulSetMemory, statefulSetMemoryRequest,
|
||||
statefulSetMemoryLimit, desiredPods, currentPods) return -1 as a sentinel
|
||||
when no data is available for that field.'
|
||||
Also reports whether the requested time range falls before the data retention
|
||||
boundary. Numeric metric fields (statefulSetCPU, statefulSetCPURequest, statefulSetCPULimit,
|
||||
statefulSetMemory, statefulSetMemoryRequest, statefulSetMemoryLimit, desiredPods,
|
||||
currentPods) return -1 as a sentinel when no data is available for that field.'
|
||||
operationId: ListStatefulSets
|
||||
requestBody:
|
||||
content:
|
||||
@@ -15662,16 +15861,20 @@ paths:
|
||||
summary: List metric names
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/{metric_name}/alerts:
|
||||
/api/v2/metrics/alerts:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns associated alerts for a specified metric
|
||||
operationId: GetMetricAlerts
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
required: true
|
||||
schema:
|
||||
description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
@@ -15726,28 +15929,36 @@ paths:
|
||||
summary: Get metric alerts
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/{metric_name}/attributes:
|
||||
/api/v2/metrics/attributes:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns attribute keys and their unique values for
|
||||
a specified metric
|
||||
operationId: GetMetricAttributes
|
||||
parameters:
|
||||
- in: query
|
||||
name: start
|
||||
schema:
|
||||
nullable: true
|
||||
type: integer
|
||||
- in: query
|
||||
name: end
|
||||
schema:
|
||||
nullable: true
|
||||
type: integer
|
||||
- in: path
|
||||
name: metric_name
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
required: true
|
||||
schema:
|
||||
description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
type: string
|
||||
- description: Start of the time range as a Unix timestamp in milliseconds.
|
||||
in: query
|
||||
name: start
|
||||
schema:
|
||||
description: Start of the time range as a Unix timestamp in milliseconds.
|
||||
nullable: true
|
||||
type: integer
|
||||
- description: End of the time range as a Unix timestamp in milliseconds.
|
||||
in: query
|
||||
name: end
|
||||
schema:
|
||||
description: End of the time range as a Unix timestamp in milliseconds.
|
||||
nullable: true
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -15801,16 +16012,20 @@ paths:
|
||||
summary: Get metric attributes
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/{metric_name}/dashboards:
|
||||
/api/v2/metrics/dashboards:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns associated dashboards for a specified metric
|
||||
operationId: GetMetricDashboards
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
required: true
|
||||
schema:
|
||||
description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
@@ -15865,17 +16080,21 @@ paths:
|
||||
summary: Get metric dashboards
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/{metric_name}/highlights:
|
||||
/api/v2/metrics/highlights:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns highlights like number of datapoints, totaltimeseries,
|
||||
active time series, last received time for a specified metric
|
||||
operationId: GetMetricHighlights
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
required: true
|
||||
schema:
|
||||
description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
@@ -15930,17 +16149,79 @@ paths:
|
||||
summary: Get metric highlights
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/{metric_name}/metadata:
|
||||
/api/v2/metrics/inspect:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Returns raw time series data points for a metric within a time
|
||||
range (max 30 minutes). Each series includes labels and timestamp/value pairs.
|
||||
operationId: InspectMetrics
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsResponse'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Inspect raw metric data points
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/metadata:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns metadata information like metric description,
|
||||
unit, type, temporality, monotonicity for a specified metric
|
||||
operationId: GetMetricMetadata
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
required: true
|
||||
schema:
|
||||
description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
@@ -16000,12 +16281,6 @@ paths:
|
||||
description: This endpoint helps to update metadata information like metric
|
||||
description, unit, type, temporality, monotonicity for a specified metric
|
||||
operationId: UpdateMetricMetadata
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
@@ -16046,64 +16321,6 @@ paths:
|
||||
summary: Update metric metadata
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/inspect:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Returns raw time series data points for a metric within a time
|
||||
range (max 30 minutes). Each series includes labels and timestamp/value pairs.
|
||||
operationId: InspectMetrics
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsResponse'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Inspect raw metric data points
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/onboarding:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -90,8 +90,12 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
|
||||
// initiate agent config handler
|
||||
agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{
|
||||
Store: signoz.SQLStore,
|
||||
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController},
|
||||
Store: signoz.SQLStore,
|
||||
AgentFeatures: []agentConf.AgentFeature{
|
||||
logParsingPipelineController,
|
||||
signoz.Modules.SpanMapper,
|
||||
signoz.Modules.LLMPricingRule,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"test": "jest",
|
||||
"test:changedsince": "jest --changedSince=main --coverage --silent",
|
||||
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
|
||||
"generate:config:web-settings": "json2ts ../docs/config/web-settings.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */'"
|
||||
"generate:config:web-settings": "json2ts ./src/schemas/generated/webSettings.schema.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */'"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"role_edit": "Edit Role",
|
||||
"role_create": "Create Role",
|
||||
"members": "Members",
|
||||
"service_accounts": "Service Accounts",
|
||||
"mcp_server": "MCP Server"
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
|
||||
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
|
||||
"ROLE_DETAILS": "SigNoz | Role Details",
|
||||
"ROLE_CREATE": "SigNoz | Create Role",
|
||||
"ROLE_EDIT": "SigNoz | Edit Role",
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
|
||||
|
||||
@@ -4,14 +4,22 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetChecks200,
|
||||
GetChecksParams,
|
||||
InframonitoringtypesPostableClustersDTO,
|
||||
InframonitoringtypesPostableDaemonSetsDTO,
|
||||
InframonitoringtypesPostableDeploymentsDTO,
|
||||
@@ -39,7 +47,94 @@ import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* Checks whether the metrics and attributes required to power the infra-monitoring section selected by the 'type' query parameter (hosts, processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces, clusters, volumes) are being received. For each collector receiver or processor that contributes required metrics or attributes, lists what is present and what is missing, with a prebuilt user-facing message and a docs link per missing component. Default-enabled metrics are those expected as soon as the receiver is configured; optional metrics require 'enabled: true' in receiver config. 'ready' is true only when every missing list is empty.
|
||||
* @summary Run Infra Monitoring Setup Checks
|
||||
*/
|
||||
export const getChecks = (params: GetChecksParams, signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetChecks200>({
|
||||
url: `/api/v2/infra_monitoring/checks`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetChecksQueryKey = (params?: GetChecksParams) => {
|
||||
return [
|
||||
`/api/v2/infra_monitoring/checks`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetChecksQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getChecks>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetChecksParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetChecksQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getChecks>>> = ({
|
||||
signal,
|
||||
}) => getChecks(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getChecks>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetChecksQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getChecks>>
|
||||
>;
|
||||
export type GetChecksQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Run Infra Monitoring Setup Checks
|
||||
*/
|
||||
|
||||
export function useGetChecks<
|
||||
TData = Awaited<ReturnType<typeof getChecks>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetChecksParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetChecksQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Run Infra Monitoring Setup Checks
|
||||
*/
|
||||
export const invalidateGetChecks = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetChecksParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetChecksQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Clusters for Infra Monitoring
|
||||
*/
|
||||
export const listClusters = (
|
||||
@@ -122,7 +217,7 @@ export const useListClusters = <
|
||||
return useMutation(getListClustersMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes DaemonSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the daemonset, plus average CPU/memory request and limit utilization (daemonSetCPURequest, daemonSetCPULimit, daemonSetMemoryRequest, daemonSetMemoryLimit). Each row also reports the latest known node-level counters from kube-state-metrics: desiredNodes (k8s.daemonset.desired_scheduled_nodes, the number of nodes the daemonset wants to run on) and currentNodes (k8s.daemonset.current_scheduled_nodes, the number of nodes the daemonset currently runs on) — note these are node counts, not pod counts. It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each daemonset includes metadata attributes (k8s.daemonset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.daemonset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by daemonsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_nodes / current_nodes, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (daemonSetCPU, daemonSetCPURequest, daemonSetCPULimit, daemonSetMemory, daemonSetMemoryRequest, daemonSetMemoryLimit, desiredNodes, currentNodes) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes DaemonSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the daemonset, plus average CPU/memory request and limit utilization (daemonSetCPURequest, daemonSetCPULimit, daemonSetMemoryRequest, daemonSetMemoryLimit). Each row also reports the latest known node-level counters from kube-state-metrics: desiredNodes (k8s.daemonset.desired_scheduled_nodes, the number of nodes the daemonset wants to run on) and currentNodes (k8s.daemonset.current_scheduled_nodes, the number of nodes the daemonset currently runs on) — note these are node counts, not pod counts. It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each daemonset includes metadata attributes (k8s.daemonset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.daemonset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by daemonsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_nodes / current_nodes, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (daemonSetCPU, daemonSetCPURequest, daemonSetCPULimit, daemonSetMemory, daemonSetMemoryRequest, daemonSetMemoryLimit, desiredNodes, currentNodes) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List DaemonSets for Infra Monitoring
|
||||
*/
|
||||
export const listDaemonSets = (
|
||||
@@ -205,7 +300,7 @@ export const useListDaemonSets = <
|
||||
return useMutation(getListDaemonSetsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes Deployments with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the deployment, plus average CPU/memory request and limit utilization (deploymentCPURequest, deploymentCPULimit, deploymentMemoryRequest, deploymentMemoryLimit). Each row also reports the latest known desiredPods (k8s.deployment.desired) and availablePods (k8s.deployment.available) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each deployment includes metadata attributes (k8s.deployment.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.deployment.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by deployments in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / available_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest, deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods, availablePods) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes Deployments with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the deployment, plus average CPU/memory request and limit utilization (deploymentCPURequest, deploymentCPULimit, deploymentMemoryRequest, deploymentMemoryLimit). Each row also reports the latest known desiredPods (k8s.deployment.desired) and availablePods (k8s.deployment.available) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each deployment includes metadata attributes (k8s.deployment.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.deployment.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by deployments in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / available_pods, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest, deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods, availablePods) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Deployments for Infra Monitoring
|
||||
*/
|
||||
export const listDeployments = (
|
||||
@@ -288,7 +383,7 @@ export const useListDeployments = <
|
||||
return useMutation(getListDeploymentsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Hosts for Infra Monitoring
|
||||
*/
|
||||
export const listHosts = (
|
||||
@@ -371,7 +466,7 @@ export const useListHosts = <
|
||||
return useMutation(getListHostsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes Jobs with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the job, plus average CPU/memory request and limit utilization (jobCPURequest, jobCPULimit, jobMemoryRequest, jobMemoryLimit). Each row also reports the latest known job-level counters from kube-state-metrics: desiredSuccessfulPods (k8s.job.desired_successful_pods, the target completion count), activePods (k8s.job.active_pods), failedPods (k8s.job.failed_pods, cumulative across the lifetime of the job), and successfulPods (k8s.job.successful_pods, cumulative). It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value); note podCountsByPhase.failed (current pod-phase) is distinct from failedPods (cumulative job kube-state-metric). Each job includes metadata attributes (k8s.job.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.job.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by jobs in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_successful_pods / active_pods / failed_pods / successful_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes Jobs with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the job, plus average CPU/memory request and limit utilization (jobCPURequest, jobCPULimit, jobMemoryRequest, jobMemoryLimit). Each row also reports the latest known job-level counters from kube-state-metrics: desiredSuccessfulPods (k8s.job.desired_successful_pods, the target completion count), activePods (k8s.job.active_pods), failedPods (k8s.job.failed_pods, cumulative across the lifetime of the job), and successfulPods (k8s.job.successful_pods, cumulative). It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value); note podCountsByPhase.failed (current pod-phase) is distinct from failedPods (cumulative job kube-state-metric). Each job includes metadata attributes (k8s.job.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.job.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by jobs in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_successful_pods / active_pods / failed_pods / successful_pods, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Jobs for Infra Monitoring
|
||||
*/
|
||||
export const listJobs = (
|
||||
@@ -454,7 +549,7 @@ export const useListJobs = <
|
||||
return useMutation(getListJobsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Namespaces for Infra Monitoring
|
||||
*/
|
||||
export const listNamespaces = (
|
||||
@@ -537,7 +632,7 @@ export const useListNamespaces = <
|
||||
return useMutation(getListNamespacesMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Nodes for Infra Monitoring
|
||||
*/
|
||||
export const listNodes = (
|
||||
@@ -620,7 +715,7 @@ export const useListNodes = <
|
||||
return useMutation(getListNodesMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Pods for Infra Monitoring
|
||||
*/
|
||||
export const listPods = (
|
||||
@@ -703,7 +798,7 @@ export const useListPods = <
|
||||
return useMutation(getListPodsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes persistent volume claims (PVCs) with key volume metrics: available bytes, capacity bytes, usage (capacity - available), inodes, free inodes, and used inodes. Each row also includes metadata attributes (k8s.persistentvolumeclaim.name, k8s.pod.uid, k8s.pod.name, k8s.namespace.name, k8s.node.name, k8s.statefulset.name, k8s.cluster.name). Supports filtering via a filter expression, custom groupBy to aggregate volumes by any attribute, ordering by any of the six metrics (available, capacity, usage, inodes, inodes_free, inodes_used), and pagination via offset/limit. The response type is 'list' for the default k8s.persistentvolumeclaim.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates volumes in the group. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (volumeAvailable, volumeCapacity, volumeUsage, volumeInodes, volumeInodesFree, volumeInodesUsed) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes persistent volume claims (PVCs) with key volume metrics: available bytes, capacity bytes, usage (capacity - available), inodes, free inodes, and used inodes. Each row also includes metadata attributes (k8s.persistentvolumeclaim.name, k8s.pod.uid, k8s.pod.name, k8s.namespace.name, k8s.node.name, k8s.statefulset.name, k8s.cluster.name). Supports filtering via a filter expression, custom groupBy to aggregate volumes by any attribute, ordering by any of the six metrics (available, capacity, usage, inodes, inodes_free, inodes_used), and pagination via offset/limit. The response type is 'list' for the default k8s.persistentvolumeclaim.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates volumes in the group. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (volumeAvailable, volumeCapacity, volumeUsage, volumeInodes, volumeInodesFree, volumeInodesUsed) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Volumes for Infra Monitoring
|
||||
*/
|
||||
export const listVolumes = (
|
||||
@@ -786,7 +881,7 @@ export const useListVolumes = <
|
||||
return useMutation(getListVolumesMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes StatefulSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the statefulset, plus average CPU/memory request and limit utilization (statefulSetCPURequest, statefulSetCPULimit, statefulSetMemoryRequest, statefulSetMemoryLimit). Each row also reports the latest known desiredPods (k8s.statefulset.desired_pods) and currentPods (k8s.statefulset.current_pods) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each statefulset includes metadata attributes (k8s.statefulset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.statefulset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by statefulsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / current_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (statefulSetCPU, statefulSetCPURequest, statefulSetCPULimit, statefulSetMemory, statefulSetMemoryRequest, statefulSetMemoryLimit, desiredPods, currentPods) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes StatefulSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the statefulset, plus average CPU/memory request and limit utilization (statefulSetCPURequest, statefulSetCPULimit, statefulSetMemoryRequest, statefulSetMemoryLimit). Each row also reports the latest known desiredPods (k8s.statefulset.desired_pods) and currentPods (k8s.statefulset.current_pods) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each statefulset includes metadata attributes (k8s.statefulset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.statefulset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by statefulsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / current_pods, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (statefulSetCPU, statefulSetCPURequest, statefulSetCPULimit, statefulSetMemory, statefulSetMemoryRequest, statefulSetMemoryLimit, desiredPods, currentPods) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List StatefulSets for Infra Monitoring
|
||||
*/
|
||||
export const listStatefulSets = (
|
||||
|
||||
@@ -19,16 +19,15 @@ import type {
|
||||
|
||||
import type {
|
||||
GetMetricAlerts200,
|
||||
GetMetricAlertsPathParameters,
|
||||
GetMetricAlertsParams,
|
||||
GetMetricAttributes200,
|
||||
GetMetricAttributesParams,
|
||||
GetMetricAttributesPathParameters,
|
||||
GetMetricDashboards200,
|
||||
GetMetricDashboardsPathParameters,
|
||||
GetMetricDashboardsParams,
|
||||
GetMetricHighlights200,
|
||||
GetMetricHighlightsPathParameters,
|
||||
GetMetricHighlightsParams,
|
||||
GetMetricMetadata200,
|
||||
GetMetricMetadataPathParameters,
|
||||
GetMetricMetadataParams,
|
||||
GetMetricsOnboardingStatus200,
|
||||
GetMetricsStats200,
|
||||
GetMetricsTreemap200,
|
||||
@@ -40,7 +39,6 @@ import type {
|
||||
MetricsexplorertypesTreemapRequestDTO,
|
||||
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateMetricMetadataPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -146,27 +144,26 @@ export const invalidateListMetrics = async (
|
||||
* @summary Get metric alerts
|
||||
*/
|
||||
export const getMetricAlerts = (
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
params: GetMetricAlertsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricAlerts200>({
|
||||
url: `/api/v2/metrics/${metricName}/alerts`,
|
||||
url: `/api/v2/metrics/alerts`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricAlertsQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricAlertsPathParameters) => {
|
||||
return [`/api/v2/metrics/${metricName}/alerts`] as const;
|
||||
export const getGetMetricAlertsQueryKey = (params?: GetMetricAlertsParams) => {
|
||||
return [`/api/v2/metrics/alerts`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricAlertsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
params: GetMetricAlertsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
@@ -177,19 +174,13 @@ export const getGetMetricAlertsQueryOptions = <
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricAlertsQueryKey({ metricName });
|
||||
const queryKey = queryOptions?.queryKey ?? getGetMetricAlertsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMetricAlerts>>> = ({
|
||||
signal,
|
||||
}) => getMetricAlerts({ metricName }, signal);
|
||||
}) => getMetricAlerts(params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -209,7 +200,7 @@ export function useGetMetricAlerts<
|
||||
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
params: GetMetricAlertsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
@@ -218,7 +209,7 @@ export function useGetMetricAlerts<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricAlertsQueryOptions({ metricName }, options);
|
||||
const queryOptions = getGetMetricAlertsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -232,11 +223,11 @@ export function useGetMetricAlerts<
|
||||
*/
|
||||
export const invalidateGetMetricAlerts = async (
|
||||
queryClient: QueryClient,
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
params: GetMetricAlertsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricAlertsQueryKey({ metricName }) },
|
||||
{ queryKey: getGetMetricAlertsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -248,12 +239,11 @@ export const invalidateGetMetricAlerts = async (
|
||||
* @summary Get metric attributes
|
||||
*/
|
||||
export const getMetricAttributes = (
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
params: GetMetricAttributesParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricAttributes200>({
|
||||
url: `/api/v2/metrics/${metricName}/attributes`,
|
||||
url: `/api/v2/metrics/attributes`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
@@ -261,21 +251,16 @@ export const getMetricAttributes = (
|
||||
};
|
||||
|
||||
export const getGetMetricAttributesQueryKey = (
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v2/metrics/${metricName}/attributes`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
return [`/api/v2/metrics/attributes`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricAttributesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
params: GetMetricAttributesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
@@ -287,19 +272,13 @@ export const getGetMetricAttributesQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetMetricAttributesQueryKey({ metricName }, params);
|
||||
queryOptions?.queryKey ?? getGetMetricAttributesQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricAttributes>>
|
||||
> = ({ signal }) => getMetricAttributes({ metricName }, params, signal);
|
||||
> = ({ signal }) => getMetricAttributes(params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -319,8 +298,7 @@ export function useGetMetricAttributes<
|
||||
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
params: GetMetricAttributesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
@@ -329,11 +307,7 @@ export function useGetMetricAttributes<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricAttributesQueryOptions(
|
||||
{ metricName },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
const queryOptions = getGetMetricAttributesQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -347,12 +321,11 @@ export function useGetMetricAttributes<
|
||||
*/
|
||||
export const invalidateGetMetricAttributes = async (
|
||||
queryClient: QueryClient,
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
params: GetMetricAttributesParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricAttributesQueryKey({ metricName }, params) },
|
||||
{ queryKey: getGetMetricAttributesQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -364,27 +337,28 @@ export const invalidateGetMetricAttributes = async (
|
||||
* @summary Get metric dashboards
|
||||
*/
|
||||
export const getMetricDashboards = (
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
params: GetMetricDashboardsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricDashboards200>({
|
||||
url: `/api/v2/metrics/${metricName}/dashboards`,
|
||||
url: `/api/v2/metrics/dashboards`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricDashboardsPathParameters) => {
|
||||
return [`/api/v2/metrics/${metricName}/dashboards`] as const;
|
||||
export const getGetMetricDashboardsQueryKey = (
|
||||
params?: GetMetricDashboardsParams,
|
||||
) => {
|
||||
return [`/api/v2/metrics/dashboards`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
params: GetMetricDashboardsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
@@ -396,18 +370,13 @@ export const getGetMetricDashboardsQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey({ metricName });
|
||||
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>
|
||||
> = ({ signal }) => getMetricDashboards({ metricName }, signal);
|
||||
> = ({ signal }) => getMetricDashboards(params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -427,7 +396,7 @@ export function useGetMetricDashboards<
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
params: GetMetricDashboardsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
@@ -436,10 +405,7 @@ export function useGetMetricDashboards<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricDashboardsQueryOptions(
|
||||
{ metricName },
|
||||
options,
|
||||
);
|
||||
const queryOptions = getGetMetricDashboardsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -453,11 +419,11 @@ export function useGetMetricDashboards<
|
||||
*/
|
||||
export const invalidateGetMetricDashboards = async (
|
||||
queryClient: QueryClient,
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
params: GetMetricDashboardsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricDashboardsQueryKey({ metricName }) },
|
||||
{ queryKey: getGetMetricDashboardsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -469,27 +435,28 @@ export const invalidateGetMetricDashboards = async (
|
||||
* @summary Get metric highlights
|
||||
*/
|
||||
export const getMetricHighlights = (
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
params: GetMetricHighlightsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricHighlights200>({
|
||||
url: `/api/v2/metrics/${metricName}/highlights`,
|
||||
url: `/api/v2/metrics/highlights`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricHighlightsQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricHighlightsPathParameters) => {
|
||||
return [`/api/v2/metrics/${metricName}/highlights`] as const;
|
||||
export const getGetMetricHighlightsQueryKey = (
|
||||
params?: GetMetricHighlightsParams,
|
||||
) => {
|
||||
return [`/api/v2/metrics/highlights`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricHighlightsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
params: GetMetricHighlightsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
@@ -501,18 +468,13 @@ export const getGetMetricHighlightsQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey({ metricName });
|
||||
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>
|
||||
> = ({ signal }) => getMetricHighlights({ metricName }, signal);
|
||||
> = ({ signal }) => getMetricHighlights(params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -532,7 +494,7 @@ export function useGetMetricHighlights<
|
||||
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
params: GetMetricHighlightsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
@@ -541,10 +503,7 @@ export function useGetMetricHighlights<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricHighlightsQueryOptions(
|
||||
{ metricName },
|
||||
options,
|
||||
);
|
||||
const queryOptions = getGetMetricHighlightsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -558,219 +517,17 @@ export function useGetMetricHighlights<
|
||||
*/
|
||||
export const invalidateGetMetricHighlights = async (
|
||||
queryClient: QueryClient,
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
params: GetMetricHighlightsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricHighlightsQueryKey({ metricName }) },
|
||||
{ queryKey: getGetMetricHighlightsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
|
||||
* @summary Get metric metadata
|
||||
*/
|
||||
export const getMetricMetadata = (
|
||||
{ metricName }: GetMetricMetadataPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricMetadata200>({
|
||||
url: `/api/v2/metrics/${metricName}/metadata`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricMetadataQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricMetadataPathParameters) => {
|
||||
return [`/api/v2/metrics/${metricName}/metadata`] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricMetadataQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricMetadataPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey({ metricName });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>
|
||||
> = ({ signal }) => getMetricMetadata({ metricName }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricMetadataQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>
|
||||
>;
|
||||
export type GetMetricMetadataQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get metric metadata
|
||||
*/
|
||||
|
||||
export function useGetMetricMetadata<
|
||||
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricMetadataPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricMetadataQueryOptions({ metricName }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get metric metadata
|
||||
*/
|
||||
export const invalidateGetMetricMetadata = async (
|
||||
queryClient: QueryClient,
|
||||
{ metricName }: GetMetricMetadataPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricMetadataQueryKey({ metricName }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint helps to update metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
|
||||
* @summary Update metric metadata
|
||||
*/
|
||||
export const updateMetricMetadata = (
|
||||
{ metricName }: UpdateMetricMetadataPathParameters,
|
||||
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/metrics/${metricName}/metadata`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: metricsexplorertypesUpdateMetricMetadataRequestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateMetricMetadataMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricMetadataPathParameters;
|
||||
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricMetadataPathParameters;
|
||||
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateMetricMetadata'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
{
|
||||
pathParams: UpdateMetricMetadataPathParameters;
|
||||
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateMetricMetadata(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateMetricMetadataMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>
|
||||
>;
|
||||
export type UpdateMetricMetadataMutationBody =
|
||||
| BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>
|
||||
| undefined;
|
||||
export type UpdateMetricMetadataMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update metric metadata
|
||||
*/
|
||||
export const useUpdateMetricMetadata = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricMetadataPathParameters;
|
||||
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricMetadataPathParameters;
|
||||
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateMetricMetadataMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns raw time series data points for a metric within a time range (max 30 minutes). Each series includes labels and timestamp/value pairs.
|
||||
* @summary Inspect raw metric data points
|
||||
@@ -854,6 +611,188 @@ export const useInspectMetrics = <
|
||||
> => {
|
||||
return useMutation(getInspectMetricsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
|
||||
* @summary Get metric metadata
|
||||
*/
|
||||
export const getMetricMetadata = (
|
||||
params: GetMetricMetadataParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricMetadata200>({
|
||||
url: `/api/v2/metrics/metadata`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricMetadataQueryKey = (
|
||||
params?: GetMetricMetadataParams,
|
||||
) => {
|
||||
return [`/api/v2/metrics/metadata`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricMetadataQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>
|
||||
> = ({ signal }) => getMetricMetadata(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricMetadataQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>
|
||||
>;
|
||||
export type GetMetricMetadataQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get metric metadata
|
||||
*/
|
||||
|
||||
export function useGetMetricMetadata<
|
||||
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricMetadataQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get metric metadata
|
||||
*/
|
||||
export const invalidateGetMetricMetadata = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetMetricMetadataParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricMetadataQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint helps to update metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
|
||||
* @summary Update metric metadata
|
||||
*/
|
||||
export const updateMetricMetadata = (
|
||||
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/metrics/metadata`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: metricsexplorertypesUpdateMetricMetadataRequestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateMetricMetadataMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateMetricMetadata'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return updateMetricMetadata(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateMetricMetadataMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>
|
||||
>;
|
||||
export type UpdateMetricMetadataMutationBody =
|
||||
| BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>
|
||||
| undefined;
|
||||
export type UpdateMetricMetadataMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update metric metadata
|
||||
*/
|
||||
export const useUpdateMetricMetadata = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateMetricMetadataMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Lightweight endpoint that checks if any non-SigNoz metrics have been ingested, used for onboarding status detection
|
||||
* @summary Check if non-SigNoz metrics have been received
|
||||
|
||||
@@ -2094,6 +2094,45 @@ export interface AuthtypesGettableTokenDTO {
|
||||
tokenType?: string;
|
||||
}
|
||||
|
||||
export enum CoretypesKindDTO {
|
||||
anonymous = 'anonymous',
|
||||
organization = 'organization',
|
||||
role = 'role',
|
||||
serviceaccount = 'serviceaccount',
|
||||
user = 'user',
|
||||
'notification-channel' = 'notification-channel',
|
||||
'route-policy' = 'route-policy',
|
||||
'apdex-setting' = 'apdex-setting',
|
||||
'auth-domain' = 'auth-domain',
|
||||
session = 'session',
|
||||
'cloud-integration' = 'cloud-integration',
|
||||
'cloud-integration-service' = 'cloud-integration-service',
|
||||
integration = 'integration',
|
||||
dashboard = 'dashboard',
|
||||
'public-dashboard' = 'public-dashboard',
|
||||
'ingestion-key' = 'ingestion-key',
|
||||
'ingestion-limit' = 'ingestion-limit',
|
||||
pipeline = 'pipeline',
|
||||
'user-preference' = 'user-preference',
|
||||
'org-preference' = 'org-preference',
|
||||
'quick-filter' = 'quick-filter',
|
||||
'ttl-setting' = 'ttl-setting',
|
||||
rule = 'rule',
|
||||
'planned-maintenance' = 'planned-maintenance',
|
||||
'saved-view' = 'saved-view',
|
||||
'trace-funnel' = 'trace-funnel',
|
||||
'factor-password' = 'factor-password',
|
||||
'factor-api-key' = 'factor-api-key',
|
||||
license = 'license',
|
||||
subscription = 'subscription',
|
||||
logs = 'logs',
|
||||
traces = 'traces',
|
||||
metrics = 'metrics',
|
||||
'audit-logs' = 'audit-logs',
|
||||
'meter-metrics' = 'meter-metrics',
|
||||
'logs-field' = 'logs-field',
|
||||
'traces-field' = 'traces-field',
|
||||
}
|
||||
export enum CoretypesTypeDTO {
|
||||
user = 'user',
|
||||
serviceaccount = 'serviceaccount',
|
||||
@@ -2104,10 +2143,7 @@ export enum CoretypesTypeDTO {
|
||||
telemetryresource = 'telemetryresource',
|
||||
}
|
||||
export interface CoretypesResourceRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind: string;
|
||||
kind: CoretypesKindDTO;
|
||||
type: CoretypesTypeDTO;
|
||||
}
|
||||
|
||||
@@ -2243,12 +2279,12 @@ export interface AuthtypesPostableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
transactionGroups: AuthtypesTransactionGroupsDTO;
|
||||
transactionGroups?: AuthtypesTransactionGroupsDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableRotateTokenDTO {
|
||||
@@ -5422,6 +5458,121 @@ export interface GlobaltypesConfigDTO {
|
||||
mcp_url: string | null;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesCheckComponentTypeDTO {
|
||||
receiver = 'receiver',
|
||||
processor = 'processor',
|
||||
}
|
||||
export interface InframonitoringtypesAssociatedComponentDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
type: InframonitoringtypesCheckComponentTypeDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesAttributesComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
attributes: string[] | null;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesCheckTypeDTO {
|
||||
hosts = 'hosts',
|
||||
processes = 'processes',
|
||||
pods = 'pods',
|
||||
nodes = 'nodes',
|
||||
deployments = 'deployments',
|
||||
daemonsets = 'daemonsets',
|
||||
statefulsets = 'statefulsets',
|
||||
jobs = 'jobs',
|
||||
namespaces = 'namespaces',
|
||||
clusters = 'clusters',
|
||||
volumes = 'volumes',
|
||||
}
|
||||
export interface InframonitoringtypesMissingMetricsComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
documentationLink: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
metrics: string[] | null;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesMissingAttributesComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
attributes: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
documentationLink: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesMetricsComponentEntryDTO {
|
||||
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
metrics: string[] | null;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesChecksDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
missingDefaultEnabledMetrics:
|
||||
| InframonitoringtypesMissingMetricsComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
missingOptionalMetrics:
|
||||
| InframonitoringtypesMissingMetricsComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
missingRequiredAttributes:
|
||||
| InframonitoringtypesMissingAttributesComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
presentDefaultEnabledMetrics:
|
||||
| InframonitoringtypesMetricsComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
presentOptionalMetrics: InframonitoringtypesMetricsComponentEntryDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
presentRequiredAttributes:
|
||||
| InframonitoringtypesAttributesComponentEntryDTO[]
|
||||
| null;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ready: boolean;
|
||||
type: InframonitoringtypesCheckTypeDTO;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -5499,13 +5650,6 @@ export interface InframonitoringtypesClusterRecordDTO {
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesRequiredMetricsCheckDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
missingMetrics: string[] | null;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesResponseTypeDTO {
|
||||
list = 'list',
|
||||
grouped_list = 'grouped_list',
|
||||
@@ -5541,7 +5685,6 @@ export interface InframonitoringtypesClustersDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesClusterRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -5619,7 +5762,6 @@ export interface InframonitoringtypesDaemonSetsDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesDaemonSetRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -5697,7 +5839,6 @@ export interface InframonitoringtypesDeploymentsDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesDeploymentRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -5783,7 +5924,6 @@ export interface InframonitoringtypesHostsDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesHostRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -5869,7 +6009,6 @@ export interface InframonitoringtypesJobsDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesJobRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -5919,7 +6058,6 @@ export interface InframonitoringtypesNamespacesDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesNamespaceRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -5986,7 +6124,6 @@ export interface InframonitoringtypesNodesDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesNodeRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -6070,7 +6207,6 @@ export interface InframonitoringtypesPodsDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesPodRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -6418,7 +6554,6 @@ export interface InframonitoringtypesStatefulSetsDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesStatefulSetRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -6487,7 +6622,6 @@ export interface InframonitoringtypesVolumesDTO {
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesVolumeRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -10210,6 +10344,21 @@ export type Healthz503 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetChecksParams = {
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
type: InframonitoringtypesCheckTypeDTO;
|
||||
};
|
||||
|
||||
export type GetChecks200 = {
|
||||
data: InframonitoringtypesChecksDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListClusters200 = {
|
||||
data: InframonitoringtypesClustersDTO;
|
||||
/**
|
||||
@@ -10334,9 +10483,14 @@ export type ListMetrics200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricAlertsPathParameters = {
|
||||
export type GetMetricAlertsParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
|
||||
*/
|
||||
metricName: string;
|
||||
};
|
||||
|
||||
export type GetMetricAlerts200 = {
|
||||
data: MetricsexplorertypesMetricAlertsResponseDTO;
|
||||
/**
|
||||
@@ -10345,18 +10499,20 @@ export type GetMetricAlerts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricAttributesPathParameters = {
|
||||
metricName: string;
|
||||
};
|
||||
export type GetMetricAttributesParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
|
||||
*/
|
||||
metricName: string;
|
||||
/**
|
||||
* @type integer,null
|
||||
* @description undefined
|
||||
* @description Start of the time range as a Unix timestamp in milliseconds.
|
||||
*/
|
||||
start?: number | null;
|
||||
/**
|
||||
* @type integer,null
|
||||
* @description undefined
|
||||
* @description End of the time range as a Unix timestamp in milliseconds.
|
||||
*/
|
||||
end?: number | null;
|
||||
};
|
||||
@@ -10369,9 +10525,14 @@ export type GetMetricAttributes200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricDashboardsPathParameters = {
|
||||
export type GetMetricDashboardsParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
|
||||
*/
|
||||
metricName: string;
|
||||
};
|
||||
|
||||
export type GetMetricDashboards200 = {
|
||||
data: MetricsexplorertypesMetricDashboardsResponseDTO;
|
||||
/**
|
||||
@@ -10380,9 +10541,14 @@ export type GetMetricDashboards200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricHighlightsPathParameters = {
|
||||
export type GetMetricHighlightsParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
|
||||
*/
|
||||
metricName: string;
|
||||
};
|
||||
|
||||
export type GetMetricHighlights200 = {
|
||||
data: MetricsexplorertypesMetricHighlightsResponseDTO;
|
||||
/**
|
||||
@@ -10391,22 +10557,24 @@ export type GetMetricHighlights200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricMetadataPathParameters = {
|
||||
metricName: string;
|
||||
};
|
||||
export type GetMetricMetadata200 = {
|
||||
data: MetricsexplorertypesMetricMetadataDTO;
|
||||
export type InspectMetrics200 = {
|
||||
data: MetricsexplorertypesInspectMetricsResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateMetricMetadataPathParameters = {
|
||||
export type GetMetricMetadataParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
|
||||
*/
|
||||
metricName: string;
|
||||
};
|
||||
export type InspectMetrics200 = {
|
||||
data: MetricsexplorertypesInspectMetricsResponseDTO;
|
||||
|
||||
export type GetMetricMetadata200 = {
|
||||
data: MetricsexplorertypesMetricMetadataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,8 @@ interface ErrorInPlaceProps {
|
||||
width?: string | number;
|
||||
/** Custom content instead of ErrorContent */
|
||||
children?: ReactNode;
|
||||
/** Test ID for testing */
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,7 @@ function ErrorInPlace({
|
||||
height = '100%',
|
||||
width = '100%',
|
||||
children,
|
||||
'data-testid': dataTestId,
|
||||
}: ErrorInPlaceProps): JSX.Element {
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
@@ -59,7 +62,11 @@ function ErrorInPlace({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`error-in-place ${className}`.trim()} style={containerStyle}>
|
||||
<div
|
||||
className={`error-in-place ${className}`.trim()}
|
||||
style={containerStyle}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{children || <ErrorContent error={error} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,13 +5,9 @@ describe('PermissionDeniedFullPage', () => {
|
||||
it('renders the title and subtitle with the permissionName interpolated', () => {
|
||||
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Uh-oh! You don't have permission to view this page."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Uh-oh! You are not authorized')).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Please ask your SigNoz administrator to grant access/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with a different permissionName', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CircleSlash2 } from '@signozhq/icons';
|
||||
|
||||
import styles from './PermissionDeniedFullPage.module.scss';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
interface PermissionDeniedFullPageProps {
|
||||
permissionName: string;
|
||||
@@ -10,18 +11,18 @@ interface PermissionDeniedFullPageProps {
|
||||
function PermissionDeniedFullPage({
|
||||
permissionName,
|
||||
}: PermissionDeniedFullPageProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<span className={styles.icon}>
|
||||
<CircleSlash2 color={Style.CALLOUT_WARNING_TITLE} size={14} />
|
||||
</span>
|
||||
<p className={styles.title}>
|
||||
Uh-oh! You don't have permission to view this page.
|
||||
</p>
|
||||
<p className={styles.title}>Uh-oh! You are not authorized</p>
|
||||
<p className={styles.subtitle}>
|
||||
You need <code className={styles.permission}>{permissionName}</code> to
|
||||
view this page. Please ask your SigNoz administrator to grant access.
|
||||
<code className={styles.permission}>user/{user.id}</code> is not authorized
|
||||
to perform <code className={styles.permission}>{permissionName}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { LockKeyhole } from '@signozhq/icons';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Check, Copy, LockKeyhole } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
@@ -46,6 +48,23 @@ function OverviewTab({
|
||||
saveErrors = [],
|
||||
}: OverviewTabProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const [hasCopiedId, setHasCopiedId] = useState(false);
|
||||
|
||||
const handleCopyId = useCallback((): void => {
|
||||
if (account.id) {
|
||||
copyToClipboard(account.id);
|
||||
setHasCopiedId(true);
|
||||
}
|
||||
}, [account.id, copyToClipboard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCopiedId) {
|
||||
const timer = setTimeout(() => setHasCopiedId(false), 2000);
|
||||
return (): void => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [hasCopiedId]);
|
||||
|
||||
const formatTimestamp = useCallback(
|
||||
(ts: string | null | undefined): string => {
|
||||
@@ -93,6 +112,17 @@ function OverviewTab({
|
||||
</label>
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{account.id || '—'}</span>
|
||||
{account.id && (
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
onClick={handleCopyId}
|
||||
className="sa-drawer__copy-btn"
|
||||
data-testid="copy-id-btn"
|
||||
>
|
||||
{hasCopiedId ? <Check size={14} /> : <Copy size={14} />}
|
||||
</Button>
|
||||
)}
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -203,6 +203,19 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
color: var(--foreground);
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__disabled-roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { GuardAuthZ } from 'components/GuardAuthZ/GuardAuthZ';
|
||||
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
@@ -477,15 +476,9 @@ function ServiceAccountDrawer({
|
||||
!isAccountLoading &&
|
||||
!isAccountError &&
|
||||
selectedAccountId && (
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object={`serviceaccount:${selectedAccountId}`}
|
||||
fallbackOnNoPermissions={(): JSX.Element => (
|
||||
<PermissionDeniedCallout permissionName="serviceaccount:read" />
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview && account && (
|
||||
<>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview &&
|
||||
(canRead && account ? (
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
@@ -504,23 +497,24 @@ function ServiceAccountDrawer({
|
||||
onRefetchRoles={refetchRoles}
|
||||
saveErrors={saveErrors}
|
||||
/>
|
||||
)}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys &&
|
||||
(canListKeys ? (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDeleted}
|
||||
canUpdate={canUpdate}
|
||||
accountId={selectedAccountId}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
) : (
|
||||
<PermissionDeniedCallout permissionName="factor-api-key:list" />
|
||||
))}
|
||||
</>
|
||||
</GuardAuthZ>
|
||||
) : (
|
||||
<PermissionDeniedCallout permissionName="serviceaccount:read" />
|
||||
))}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys &&
|
||||
(canListKeys ? (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDeleted}
|
||||
canUpdate={canUpdate}
|
||||
accountId={selectedAccountId}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
) : (
|
||||
<PermissionDeniedCallout permissionName="factor-api-key:list" />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ jest.mock('providers/Timezone', () => ({
|
||||
},
|
||||
updateTimezone: jest.fn(),
|
||||
formatTimezoneAdjustedTimestamp: jest.fn(() => 'mock-date'),
|
||||
formatTimezoneAdjustedTimestampOptional: jest.fn(() => 'mock-date'),
|
||||
isAdaptationEnabled: true,
|
||||
setIsAdaptationEnabled: jest.fn(),
|
||||
}),
|
||||
|
||||
@@ -56,7 +56,9 @@ const ROUTES = {
|
||||
TRACE_EXPLORER: '/trace-explorer',
|
||||
BILLING: '/settings/billing',
|
||||
ROLES_SETTINGS: '/settings/roles',
|
||||
ROLE_CREATE: '/settings/roles/new',
|
||||
ROLE_DETAILS: '/settings/roles/:roleId',
|
||||
ROLE_EDIT: '/settings/roles/:roleId/edit',
|
||||
MEMBERS_SETTINGS: '/settings/members',
|
||||
SUPPORT: '/support',
|
||||
LOGS_SAVE_VIEWS: '/logs/saved-views',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
.members-settings {
|
||||
.members-settings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.members-settings {
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -160,7 +160,7 @@ function MembersSettings(): JSX.Element {
|
||||
}, [refetchUsers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="members-settings-page">
|
||||
<div className="members-settings">
|
||||
<div className="members-settings__header">
|
||||
<h1 className="members-settings__title">Members</h1>
|
||||
@@ -231,7 +231,7 @@ function MembersSettings(): JSX.Element {
|
||||
onClose={handleDrawerClose}
|
||||
onComplete={handleMemberEditComplete}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -191,9 +191,6 @@ function TimeSeries({
|
||||
if (metrics[0] && yAxisUnit) {
|
||||
updateMetricMetadata(
|
||||
{
|
||||
pathParams: {
|
||||
metricName: metricNames[0],
|
||||
},
|
||||
data: buildUpdateMetricYAxisUnitPayload(
|
||||
metricNames[0],
|
||||
metrics[0],
|
||||
|
||||
@@ -48,18 +48,14 @@ function AllAttributes({
|
||||
isLoading: isLoadingAttributes,
|
||||
isError: isErrorAttributes,
|
||||
refetch: refetchAttributes,
|
||||
} = useGetMetricAttributes(
|
||||
{
|
||||
metricName,
|
||||
},
|
||||
{
|
||||
start: minTime ? Math.floor(minTime / 1000000) : undefined,
|
||||
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
|
||||
},
|
||||
);
|
||||
} = useGetMetricAttributes({
|
||||
metricName,
|
||||
start: minTime ? Math.floor(minTime / 1000000) : undefined,
|
||||
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
|
||||
});
|
||||
|
||||
const attributes = useMemo(
|
||||
() => attributesData?.data?.attributes ?? [],
|
||||
() => attributesData?.data.attributes ?? [],
|
||||
[attributesData],
|
||||
);
|
||||
|
||||
|
||||
@@ -237,9 +237,6 @@ function Metadata({
|
||||
const handleSave = useCallback(() => {
|
||||
updateMetricMetadata(
|
||||
{
|
||||
pathParams: {
|
||||
metricName,
|
||||
},
|
||||
data: transformUpdateMetricMetadataRequest(metricName, metricMetadataState),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,7 +56,7 @@ function MetricDetails({
|
||||
);
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
if (!metricMetadataResponse?.data) {
|
||||
if (!metricMetadataResponse) {
|
||||
return null;
|
||||
}
|
||||
const { type, description, unit, temporality, isMonotonic } =
|
||||
|
||||
@@ -195,14 +195,12 @@ describe('Metadata', () => {
|
||||
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
metricName: MOCK_METRIC_NAME,
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
unit: 'By',
|
||||
isMonotonic: true,
|
||||
}),
|
||||
pathParams: {
|
||||
metricName: MOCK_METRIC_NAME,
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
.createEditRolePage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.createEditRolePageHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.createEditRolePageHeaderLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.backButton {
|
||||
--button-padding: var(--spacing-3);
|
||||
}
|
||||
|
||||
.createEditRolePageActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.unsavedIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin-right: var(--spacing-4);
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.unsavedText {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.createEditRolePageContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.createEditRolePageForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.formRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.formField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-disabled-background: var(--l2-background);
|
||||
|
||||
input::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.createEditRolePageDivider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { matchPath, useHistory, useLocation } from 'react-router-dom';
|
||||
import { ArrowLeft, SolidAlertTriangle } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Skeleton } from 'antd';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
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';
|
||||
|
||||
function CreateEditRolePage(): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const urlQuery = useUrlQuery();
|
||||
const match = matchPath<{ roleId: string }>(pathname, {
|
||||
path: ROUTES.ROLE_DETAILS,
|
||||
});
|
||||
const roleId = match?.params?.roleId ?? 'new';
|
||||
const roleName = urlQuery.get('name') ?? '';
|
||||
const [hasJsonError, setHasJsonError] = useState(false);
|
||||
const { isRolesEnabled, isLoading: isFeatureGateLoading } =
|
||||
useRolesFeatureGate();
|
||||
|
||||
const {
|
||||
formData,
|
||||
editorMode,
|
||||
setEditorMode,
|
||||
resources,
|
||||
setResources,
|
||||
isLoading,
|
||||
isSaving,
|
||||
hasUnsavedChanges,
|
||||
handleSave,
|
||||
handleCancel,
|
||||
handleFormChange,
|
||||
saveError,
|
||||
validationErrors,
|
||||
isCreateMode,
|
||||
hasRequiredPermission,
|
||||
isAuthZLoading,
|
||||
deniedPermission,
|
||||
loadError,
|
||||
} = useCreateEditRolePageActions(roleId, roleName);
|
||||
|
||||
const { isBlocked, confirmNavigation, cancelNavigation, allowNextNavigation } =
|
||||
useNavigationBlocker(hasUnsavedChanges);
|
||||
|
||||
const handleSaveAndNavigate = useCallback(async (): Promise<void> => {
|
||||
if (hasJsonError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await handleSave();
|
||||
if (success) {
|
||||
allowNextNavigation();
|
||||
if (isCreateMode) {
|
||||
history.push(ROUTES.ROLES_SETTINGS);
|
||||
} else {
|
||||
const viewUrl = `${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}?name=${encodeURIComponent(roleName)}`;
|
||||
history.push(viewUrl);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
handleSave,
|
||||
allowNextNavigation,
|
||||
history,
|
||||
hasJsonError,
|
||||
isCreateMode,
|
||||
roleId,
|
||||
roleName,
|
||||
]);
|
||||
|
||||
if (!hasRequiredPermission && !isAuthZLoading) {
|
||||
return <PermissionDeniedFullPage permissionName={deniedPermission} />;
|
||||
}
|
||||
|
||||
if (!isRolesEnabled && !isFeatureGateLoading) {
|
||||
return (
|
||||
<div
|
||||
className={styles.createEditRolePage}
|
||||
data-testid="create-edit-role-page"
|
||||
>
|
||||
<div className={styles.createEditRolePageHeader}>
|
||||
<div className={styles.createEditRolePageHeaderLeft}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={handleCancel}
|
||||
data-testid="cancel-button"
|
||||
className={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
<Typography.Title level={3}>
|
||||
{isCreateMode ? 'Create Role' : 'Edit Role'}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorInPlace
|
||||
error={
|
||||
new APIError({
|
||||
httpStatusCode: 403,
|
||||
error: {
|
||||
code: 'FEATURE_DISABLED',
|
||||
message:
|
||||
'Custom roles feature is not available. Please check your license or feature configuration.',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
data-testid="feature-gate-error-banner"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthZLoading || (isLoading && !isCreateMode) || isFeatureGateLoading) {
|
||||
return (
|
||||
<div className={styles.createEditRolePage}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div
|
||||
className={styles.createEditRolePage}
|
||||
data-testid="create-edit-role-page"
|
||||
>
|
||||
<div className={styles.createEditRolePageHeader}>
|
||||
<div className={styles.createEditRolePageHeaderLeft}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
data-testid="cancel-button"
|
||||
className={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
<Typography.Title level={3}>Failed to load role</Typography.Title>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorInPlace error={loadError} data-testid="role-load-error-banner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.createEditRolePage}
|
||||
data-testid="create-edit-role-page"
|
||||
>
|
||||
<div className={styles.createEditRolePageHeader}>
|
||||
<div className={styles.createEditRolePageHeaderLeft}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
data-testid="cancel-button"
|
||||
className={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
<Typography.Title level={3}>
|
||||
{isCreateMode
|
||||
? 'Create Role'
|
||||
: `Role - ${formData.name || 'Loading role...'}`}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<div className={styles.createEditRolePageActions}>
|
||||
{hasUnsavedChanges && (
|
||||
<div className={styles.unsavedIndicator}>
|
||||
<span className={styles.unsavedDot} />
|
||||
<Typography as="span" size="base" className={styles.unsavedText}>
|
||||
Unsaved changes
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSaveAndNavigate}
|
||||
loading={isSaving}
|
||||
disabled={!hasUnsavedChanges || hasJsonError}
|
||||
data-testid="save-button"
|
||||
>
|
||||
{isCreateMode ? 'Create role' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<ErrorInPlace
|
||||
error={saveError}
|
||||
height="auto"
|
||||
bordered
|
||||
data-testid="save-error-banner"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.createEditRolePageContent}>
|
||||
<div className={styles.createEditRolePageForm}>
|
||||
<div className={styles.formRow}>
|
||||
{isCreateMode ? (
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-name" className={styles.formLabel}>
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="role-name"
|
||||
value={formData.name}
|
||||
onChange={(e): void => handleFormChange('name', e.target.value)}
|
||||
placeholder="my-custom-role"
|
||||
data-testid="role-name-input"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-description" className={styles.formLabel}>
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
id="role-description"
|
||||
value={formData.description}
|
||||
onChange={(e): void => handleFormChange('description', e.target.value)}
|
||||
placeholder="Custom role for the support team"
|
||||
data-testid="role-description-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.createEditRolePageDivider} />
|
||||
|
||||
<PermissionEditor
|
||||
resources={resources}
|
||||
mode={editorMode}
|
||||
onModeChange={setEditorMode}
|
||||
onResourceChange={setResources}
|
||||
onJsonValidityChange={setHasJsonError}
|
||||
isLoading={isLoading}
|
||||
validationErrors={validationErrors}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isBlocked}
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
cancelNavigation();
|
||||
}
|
||||
}}
|
||||
title="Discard unsaved changes?"
|
||||
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
|
||||
confirmText="Discard"
|
||||
confirmColor="destructive"
|
||||
cancelText="Keep editing"
|
||||
onConfirm={confirmNavigation}
|
||||
onCancel={cancelNavigation}
|
||||
data-testid="discard-changes-dialog"
|
||||
>
|
||||
<Typography>
|
||||
{isCreateMode
|
||||
? 'This new role will not be created.'
|
||||
: 'Your unsaved changes will be lost.'}
|
||||
</Typography>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateEditRolePage;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
|
||||
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderCreatePage(
|
||||
appContextOverrides?: Record<string, unknown>,
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_CREATE}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles/new', appContextOverrides },
|
||||
);
|
||||
}
|
||||
|
||||
function renderEditPage(
|
||||
roleId: string,
|
||||
roleName: string,
|
||||
appContextOverrides?: Record<string, unknown>,
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_EDIT}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: `/settings/roles/${roleId}/edit?name=${encodeURIComponent(roleName)}`,
|
||||
appContextOverrides,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('CreateEditRolePage - Feature Gate', () => {
|
||||
describe('create mode - feature disabled', () => {
|
||||
it('shows error when fine-grained authz flag is inactive', async () => {
|
||||
renderCreatePage({
|
||||
featureFlags: defaultFeatureFlags.map((f) =>
|
||||
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
|
||||
? { ...f, active: false }
|
||||
: f,
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when license is invalid', async () => {
|
||||
renderCreatePage({ activeLicense: invalidLicense });
|
||||
|
||||
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Create Role title when feature disabled in create mode', async () => {
|
||||
renderCreatePage({ activeLicense: invalidLicense });
|
||||
|
||||
await expect(screen.findByText('Create Role')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows back button when feature disabled', () => {
|
||||
renderCreatePage({ activeLicense: invalidLicense });
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('back button is enabled when feature disabled', () => {
|
||||
renderCreatePage({ activeLicense: invalidLicense });
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit mode - feature disabled', () => {
|
||||
const ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
|
||||
const ROLE_NAME = 'test-role';
|
||||
|
||||
it('shows error when fine-grained authz flag is inactive', async () => {
|
||||
renderEditPage(ROLE_ID, ROLE_NAME, {
|
||||
featureFlags: defaultFeatureFlags.map((f) =>
|
||||
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
|
||||
? { ...f, active: false }
|
||||
: f,
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when license is invalid', async () => {
|
||||
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });
|
||||
|
||||
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Edit Role title when feature disabled in edit mode', async () => {
|
||||
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });
|
||||
|
||||
await expect(screen.findByText('Edit Role')).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { mockUseAuthZDenyAll } from 'tests/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderCreatePage(): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_CREATE}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles/new' },
|
||||
);
|
||||
}
|
||||
|
||||
describe('CreateRolePage - AuthZ', () => {
|
||||
describe('permission denied', () => {
|
||||
it('shows PermissionDeniedFullPage when create permission denied', async () => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
|
||||
|
||||
renderCreatePage();
|
||||
|
||||
await expect(
|
||||
screen.findByText(/You are not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows skeleton while checking permissions', () => {
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
renderCreatePage();
|
||||
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const rolesApiBase = '*/api/v1/roles';
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
function renderCreatePage(): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_CREATE}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles/new' },
|
||||
);
|
||||
}
|
||||
|
||||
describe('CreateRolePage', () => {
|
||||
describe('initial render', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
expect(nameInput).toHaveValue('');
|
||||
});
|
||||
|
||||
it('renders empty description input', () => {
|
||||
renderCreatePage();
|
||||
|
||||
const descInput = screen.getByTestId('role-description-input');
|
||||
expect(descInput).toHaveValue('');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
renderCreatePage();
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
expect(saveBtn).toHaveTextContent('Create role');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
renderCreatePage();
|
||||
|
||||
expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('form interactions', () => {
|
||||
it('enables save button when name is entered', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'test-role');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
expect(saveBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows unsaved indicator when form modified', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'my-role');
|
||||
|
||||
await expect(
|
||||
screen.findByText('Unsaved changes'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables save button when description is entered', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
|
||||
const descInput = screen.getByTestId('role-description-input');
|
||||
await user.type(descInput, 'Some description');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
expect(saveBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel action', () => {
|
||||
it('navigates to roles list on cancel', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
|
||||
const cancelBtn = screen.getByTestId('cancel-button');
|
||||
await user.click(cancelBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('roles-list-redirect'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create success flow', () => {
|
||||
it('calls create API with form data and redirects', async () => {
|
||||
const createSpy = jest.fn();
|
||||
|
||||
server.use(
|
||||
rest.post(rolesApiBase, async (req, res, ctx) => {
|
||||
createSpy(await req.json());
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { id: 'new-role-id', name: 'my-custom-role' },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'my-custom-role');
|
||||
|
||||
const descInput = screen.getByTestId('role-description-input');
|
||||
await user.type(descInput, 'Role for testing');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'my-custom-role',
|
||||
description: 'Role for testing',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('roles-list-redirect'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create error flows', () => {
|
||||
it('does not call API when name is empty', async () => {
|
||||
const createSpy = jest.fn();
|
||||
server.use(
|
||||
rest.post(rolesApiBase, async (req, res, ctx) => {
|
||||
createSpy();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
|
||||
const descInput = screen.getByTestId('role-description-input');
|
||||
await user.type(descInput, 'Description only');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(createSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it('shows error banner when API fails', async () => {
|
||||
server.use(
|
||||
rest.post(rolesApiBase, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
error: { message: 'Role name already exists' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'duplicate-role');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('save-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByText('Role name already exists'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation errors', () => {
|
||||
it('shows validation error when Only Selected has no items', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
|
||||
const apiKeysCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeysCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
await user.click(header);
|
||||
|
||||
const readToggle = within(apiKeysCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
const onlySelectedBtn = await within(readToggle).findByText('Only selected');
|
||||
await user.click(onlySelectedBtn);
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('save-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByText(
|
||||
'Please add at least one selector for each "Only selected" permission.',
|
||||
),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import {
|
||||
mockUseAuthZDenyAll,
|
||||
mockUseAuthZGrantByPrefix,
|
||||
} from 'tests/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const EDIT_ROLE_ID = 'test-role-123';
|
||||
const EDIT_ROLE_NAME = 'test-role';
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderEditPage(): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_DETAILS}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{ initialRoute: `/settings/roles/${EDIT_ROLE_ID}?name=${EDIT_ROLE_NAME}` },
|
||||
);
|
||||
}
|
||||
|
||||
describe('EditRolePage - AuthZ', () => {
|
||||
describe('permission denied', () => {
|
||||
it('shows PermissionDeniedFullPage when read permission denied', async () => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await expect(
|
||||
screen.findByText(/You are not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows PermissionDeniedFullPage when update permission denied but read granted', async () => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantByPrefix('read'));
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await expect(
|
||||
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', () => {
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
renderEditPage();
|
||||
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('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';
|
||||
|
||||
const roleWithTransactionGroups = {
|
||||
status: 'success',
|
||||
data: {
|
||||
...customRoleResponse.data,
|
||||
transactionGroups: [
|
||||
{
|
||||
objectGroup: {
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
relation: 'read',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(roleWithTransactionGroups)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
function renderEditPage(roleId = CUSTOM_ROLE_ID): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_DETAILS} exact>
|
||||
<div data-testid="role-details-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_EDIT}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{ initialRoute: `/settings/roles/${roleId}/edit?name=Custom%20Role` },
|
||||
);
|
||||
}
|
||||
|
||||
describe('EditRolePage', () => {
|
||||
describe('loading state', () => {
|
||||
it('shows skeleton while fetching role data', () => {
|
||||
server.use(
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.delay(200), ctx.status(200), ctx.json(roleWithTransactionGroups)),
|
||||
),
|
||||
);
|
||||
|
||||
renderEditPage();
|
||||
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('load error state', () => {
|
||||
it('shows error banner when role load fails', async () => {
|
||||
server.use(
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: { message: 'Server error' } })),
|
||||
),
|
||||
);
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Failed to load role title on load error', async () => {
|
||||
server.use(
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(404), ctx.json({ error: { message: 'Not found' } })),
|
||||
),
|
||||
);
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await expect(
|
||||
screen.findByText('Failed to load role'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows back button on load error', async () => {
|
||||
server.use(
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: { message: 'Server error' } })),
|
||||
),
|
||||
);
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to view page when cancel clicked in error state', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: { message: 'Server error' } })),
|
||||
),
|
||||
);
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await waitFor(async () => {
|
||||
const cancelButton = await screen.findByTestId('cancel-button');
|
||||
await user.click(cancelButton);
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('role-details-redirect'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial render with loaded data', () => {
|
||||
it('shows role name in page title', async () => {
|
||||
renderEditPage();
|
||||
|
||||
await expect(
|
||||
screen.findByText('Role - billing-manager'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('name input is not shown in edit mode', async () => {
|
||||
renderEditPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('role-name-input')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('populates description input with existing value', async () => {
|
||||
renderEditPage();
|
||||
|
||||
await waitFor(async () => {
|
||||
const descInput = await screen.findByTestId('role-description-input');
|
||||
expect(descInput).toHaveValue(
|
||||
'Custom role for managing billing and invoices.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('description input is enabled in edit mode', async () => {
|
||||
renderEditPage();
|
||||
|
||||
const descInput = await screen.findByTestId('role-description-input');
|
||||
expect(descInput).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('save button shows "Save changes" text', async () => {
|
||||
renderEditPage();
|
||||
|
||||
const saveBtn = await screen.findByTestId('save-button');
|
||||
expect(saveBtn).toHaveTextContent('Save changes');
|
||||
});
|
||||
|
||||
it('save button is disabled when no unsaved changes', async () => {
|
||||
renderEditPage();
|
||||
|
||||
await waitFor(async () => {
|
||||
const descInput = await screen.findByTestId('role-description-input');
|
||||
expect(descInput).toHaveValue(
|
||||
'Custom role for managing billing and invoices.',
|
||||
);
|
||||
});
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
expect(saveBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('form interactions', () => {
|
||||
it('enables save button when description is modified', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderEditPage();
|
||||
|
||||
const descInput = await screen.findByTestId('role-description-input');
|
||||
await user.clear(descInput);
|
||||
await user.type(descInput, 'New description');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
expect(saveBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows unsaved indicator when description modified', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderEditPage();
|
||||
|
||||
const descInput = await screen.findByTestId('role-description-input');
|
||||
await user.type(descInput, ' updated');
|
||||
|
||||
await expect(
|
||||
screen.findByText('Unsaved changes'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables save when changes reverted to original', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderEditPage();
|
||||
|
||||
const descInput = await screen.findByTestId('role-description-input');
|
||||
const originalValue = 'Custom role for managing billing and invoices.';
|
||||
|
||||
await user.clear(descInput);
|
||||
await user.type(descInput, 'Temporary change');
|
||||
expect(screen.getByTestId('save-button')).not.toBeDisabled();
|
||||
|
||||
await user.clear(descInput);
|
||||
await user.type(descInput, originalValue);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('save-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel action', () => {
|
||||
it('navigates to view role page on cancel', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderEditPage();
|
||||
|
||||
await screen.findByTestId('role-description-input');
|
||||
|
||||
const cancelBtn = screen.getByTestId('cancel-button');
|
||||
await user.click(cancelBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('role-details-redirect'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update success flow', () => {
|
||||
it('redirects to view page after successful update', async () => {
|
||||
server.use(
|
||||
rest.put(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderEditPage();
|
||||
|
||||
const descInput = await screen.findByTestId('role-description-input');
|
||||
await user.clear(descInput);
|
||||
await user.type(descInput, 'Updated description');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('role-details-redirect'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls update API when save clicked', async () => {
|
||||
const updateSpy = jest.fn();
|
||||
|
||||
server.use(
|
||||
rest.put(`${rolesApiBase}/:id`, async (req, res, ctx) => {
|
||||
updateSpy();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderEditPage();
|
||||
|
||||
const descInput = await screen.findByTestId('role-description-input');
|
||||
await user.type(descInput, ' edited');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update error flow', () => {
|
||||
it('shows error banner when update fails with 500', async () => {
|
||||
server.use(
|
||||
rest.put(`${rolesApiBase}/:id`, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderEditPage();
|
||||
|
||||
const descInput = await screen.findByTestId('role-description-input');
|
||||
await user.type(descInput, ' changed');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('save-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error banner when update fails with 403', async () => {
|
||||
server.use(
|
||||
rest.put(`${rolesApiBase}/:id`, (_req, res, ctx) => res(ctx.status(403))),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderEditPage();
|
||||
|
||||
const descInput = await screen.findByTestId('role-description-input');
|
||||
await user.type(descInput, ' test');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('save-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error banner when update fails with 400', async () => {
|
||||
server.use(
|
||||
rest.put(`${rolesApiBase}/:id`, (_req, res, ctx) => res(ctx.status(400))),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderEditPage();
|
||||
|
||||
const descInput = await screen.findByTestId('role-description-input');
|
||||
await user.type(descInput, ' x');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('save-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission changes', () => {
|
||||
it('detects permission change as unsaved', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderEditPage();
|
||||
|
||||
await screen.findByTestId('permission-editor');
|
||||
|
||||
const apiKeysCard = await screen.findByTestId(
|
||||
'resource-card-factor-api-key',
|
||||
);
|
||||
const header = within(apiKeysCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
await user.click(header);
|
||||
|
||||
const createToggle = within(apiKeysCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
const allBtn = await within(createToggle).findByText('All');
|
||||
await user.click(allBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByText('Unsaved changes'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByTestId('save-button')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render, screen, userEvent, within } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderPage(): ReturnType<typeof render> {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_CREATE}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles/new' },
|
||||
);
|
||||
}
|
||||
|
||||
async function switchToJsonMode(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
|
||||
await user.click(jsonRadio);
|
||||
}
|
||||
|
||||
async function switchToInteractiveMode(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const interactiveRadio = screen.getByTestId(
|
||||
'permission-editor-mode-interactive',
|
||||
);
|
||||
await user.click(interactiveRadio);
|
||||
}
|
||||
|
||||
describe('JsonEditor', () => {
|
||||
describe('initial render', () => {
|
||||
it('renders JSON editor when JSON mode selected', async () => {
|
||||
renderPage();
|
||||
await switchToJsonMode();
|
||||
|
||||
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders JSON editor container div', async () => {
|
||||
renderPage();
|
||||
await switchToJsonMode();
|
||||
|
||||
const jsonEditor = screen.getByTestId('json-editor');
|
||||
expect(jsonEditor.querySelector('div')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync with interactive mode', () => {
|
||||
it('syncs changes from interactive mode when switching to JSON', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await screen.findByTestId('permission-editor');
|
||||
await switchToInteractiveMode();
|
||||
|
||||
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
await user.click(header);
|
||||
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
await user.click(await within(createToggle).findByText('All'));
|
||||
|
||||
await switchToJsonMode();
|
||||
|
||||
const jsonEditor = screen.getByTestId('json-editor');
|
||||
expect(jsonEditor).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves changes when switching back to interactive', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await screen.findByTestId('permission-editor');
|
||||
await switchToInteractiveMode();
|
||||
|
||||
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
await user.click(header);
|
||||
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
await user.click(await within(createToggle).findByText('All'));
|
||||
|
||||
await switchToJsonMode();
|
||||
|
||||
const interactiveRadio = screen.getByTestId(
|
||||
'permission-editor-mode-interactive',
|
||||
);
|
||||
await user.click(interactiveRadio);
|
||||
|
||||
const scopeToggle = within(
|
||||
screen.getByTestId('action-toggle-factor-api-key-create'),
|
||||
).getByTestId('action-toggle-scope-factor-api-key-create');
|
||||
expect(
|
||||
within(scopeToggle).getByRole('radio', { name: 'All' }),
|
||||
).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('no error shown initially with valid JSON', async () => {
|
||||
renderPage();
|
||||
await switchToJsonMode();
|
||||
|
||||
expect(screen.queryByTestId('json-editor-error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON structure', () => {
|
||||
it('produces valid transactionGroups format', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await screen.findByTestId('permission-editor');
|
||||
await switchToInteractiveMode();
|
||||
|
||||
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
await user.click(header);
|
||||
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'test-key-123{enter}');
|
||||
|
||||
await switchToJsonMode();
|
||||
|
||||
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles wildcard selector for All scope', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await screen.findByTestId('permission-editor');
|
||||
await switchToInteractiveMode();
|
||||
|
||||
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
await user.click(header);
|
||||
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-create',
|
||||
);
|
||||
await user.click(await within(createToggle).findByText('All'));
|
||||
|
||||
await switchToJsonMode();
|
||||
|
||||
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mode switching', () => {
|
||||
it('reinitializes JSON buffer on switch from interactive to JSON', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await screen.findByTestId('permission-editor');
|
||||
await switchToJsonMode();
|
||||
|
||||
const interactiveRadio = screen.getByTestId(
|
||||
'permission-editor-mode-interactive',
|
||||
);
|
||||
await user.click(interactiveRadio);
|
||||
|
||||
const apiKeyCard = await screen.findByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
await user.click(header);
|
||||
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
await user.click(await within(readToggle).findByText('All'));
|
||||
|
||||
await switchToJsonMode();
|
||||
|
||||
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
async function expandAllCards(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const expandButton = screen.getByTestId('expand-all-button');
|
||||
await user.click(expandButton);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderPage(): ReturnType<typeof render> {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_CREATE}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles/new' },
|
||||
);
|
||||
}
|
||||
|
||||
describe('PermissionEditor', () => {
|
||||
describe('mode toggle', () => {
|
||||
it('renders permission editor with testId', () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByTestId('permission-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to interactive mode', () => {
|
||||
renderPage();
|
||||
|
||||
const interactiveRadio = screen.getByTestId(
|
||||
'permission-editor-mode-interactive',
|
||||
);
|
||||
expect(interactiveRadio).toBeChecked();
|
||||
});
|
||||
|
||||
it('switches to JSON mode when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
|
||||
await user.click(jsonRadio);
|
||||
|
||||
expect(jsonRadio).toBeChecked();
|
||||
expect(screen.getByTestId('json-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches back to interactive mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
|
||||
await user.click(jsonRadio);
|
||||
|
||||
const interactiveRadio = screen.getByTestId(
|
||||
'permission-editor-mode-interactive',
|
||||
);
|
||||
await user.click(interactiveRadio);
|
||||
|
||||
expect(interactiveRadio).toBeChecked();
|
||||
expect(screen.queryByTestId('json-editor')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource cards', () => {
|
||||
it('renders all resource cards', () => {
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('resource-card-factor-api-key'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('resource-card-role')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('resource-card-serviceaccount'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resource cards are collapsed by default', () => {
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
|
||||
expect(header).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('expands resource card when header clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
|
||||
await user.click(header);
|
||||
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('collapses expanded resource card when header clicked again', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
|
||||
await user.click(header);
|
||||
await user.click(header);
|
||||
|
||||
expect(header).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('shows granted count in resource card header', async () => {
|
||||
renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
await expect(
|
||||
within(apiKeyCard).findByText(/0 \/ \d+ granted/),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('action toggles', () => {
|
||||
it('renders action toggles for each available action', async () => {
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
expect(
|
||||
within(apiKeyCard).getByTestId('action-toggle-factor-api-key-read'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(apiKeyCard).getByTestId('action-toggle-factor-api-key-read'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(apiKeyCard).getByTestId('action-toggle-factor-api-key-update'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(apiKeyCard).getByTestId('action-toggle-factor-api-key-delete'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults all actions to None scope', async () => {
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
const scopeToggle = within(createToggle).getByTestId(
|
||||
'action-toggle-scope-factor-api-key-read',
|
||||
);
|
||||
expect(
|
||||
within(scopeToggle).getByRole('radio', { name: 'None' }),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it('changes scope to All when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
const allBtn = await within(createToggle).findByText('All');
|
||||
await user.click(allBtn);
|
||||
|
||||
const scopeToggle = within(createToggle).getByTestId(
|
||||
'action-toggle-scope-factor-api-key-read',
|
||||
);
|
||||
expect(
|
||||
within(scopeToggle).getByRole('radio', { name: 'All' }),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it('updates granted count when scope changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
await user.click(await within(createToggle).findByText('All'));
|
||||
|
||||
await expect(
|
||||
within(apiKeyCard).findByText(/1 \/ \d+ granted/),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Only Selected scope', () => {
|
||||
it('shows item input selector when Only Selected is chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
const onlySelectedBtn =
|
||||
await within(createToggle).findByText('Only selected');
|
||||
await user.click(onlySelectedBtn);
|
||||
|
||||
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds item when typed and Enter pressed', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'api-key-001{enter}');
|
||||
|
||||
await expect(screen.findByText('api-key-001')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds item when Add button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'api-key-002');
|
||||
|
||||
const addBtn = screen.getByTestId('item-input-selector-add-btn');
|
||||
await user.click(addBtn);
|
||||
|
||||
await expect(screen.findByText('api-key-002')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds multiple items separated by comma', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'key-a, key-b, key-c{enter}');
|
||||
|
||||
await expect(screen.findByText('key-a')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('key-b')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('key-c')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds multiple items separated by space', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'key-x key-y key-z{enter}');
|
||||
|
||||
await expect(screen.findByText('key-x')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('key-y')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('key-z')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add duplicate items', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'same-key{enter}');
|
||||
await user.type(input, 'same-key{enter}');
|
||||
|
||||
const badges = screen.getAllByText('same-key');
|
||||
expect(badges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes item when X clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'removable-key{enter}');
|
||||
|
||||
const removeBtn = screen.getByRole('button', {
|
||||
name: /remove removable-key/i,
|
||||
});
|
||||
await user.click(removeBtn);
|
||||
|
||||
expect(screen.queryByText('removable-key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Add button disabled when input is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const addBtn = screen.getByTestId('item-input-selector-add-btn');
|
||||
expect(addBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope change confirmation dialog', () => {
|
||||
it('shows confirm dialog when leaving Only Selected with items', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'will-be-cleared{enter}');
|
||||
|
||||
await user.click(await within(createToggle).findByText('All'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Change permission scope?'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears items when confirmed', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'to-be-cleared{enter}');
|
||||
|
||||
await user.click(await within(createToggle).findByText('All'));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: /change scope/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('to-be-cleared')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps items when cancelled', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
await user.type(input, 'preserved-key{enter}');
|
||||
|
||||
await user.click(await within(createToggle).findByText('None'));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
await user.click(within(dialog).getByRole('button', { name: /cancel/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByText('preserved-key'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show dialog when leaving Only Selected with no items', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
await user.click(await within(createToggle).findByText('All'));
|
||||
|
||||
expect(
|
||||
screen.queryByText('Change permission scope?'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verbs without Only Selected option', () => {
|
||||
it('does not show Only Selected for list verb', async () => {
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const listToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-list',
|
||||
);
|
||||
|
||||
expect(
|
||||
within(listToggle).queryByText('Only selected'),
|
||||
).not.toBeInTheDocument();
|
||||
await expect(
|
||||
within(listToggle).findByText('None'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
within(listToggle).findByText('All'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapse/expand all resources', () => {
|
||||
it('shows expand/collapse toggle group', () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByTestId('toggle-all-group')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('expand-all-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('collapse-all-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands all cards when expand button clicked', async () => {
|
||||
renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
.actionToggle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: var(--spacing-8);
|
||||
bottom: 0;
|
||||
left: var(--spacing-8);
|
||||
height: 1px;
|
||||
background: var(--l2-border);
|
||||
}
|
||||
|
||||
&:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.actionToggleHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.actionToggleScopeToggle {
|
||||
flex-shrink: 0;
|
||||
width: fit-content;
|
||||
|
||||
--toggle-group-item-size: 1.4rem;
|
||||
--toggle-group-item-padding-right: 0.4rem;
|
||||
--toggle-group-item-border-style: none;
|
||||
--toggle-group-secondary-active-bg: var(--bg-robin-800);
|
||||
--toggle-group-item-align-items: baseline;
|
||||
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2);
|
||||
|
||||
button {
|
||||
min-width: 46px;
|
||||
font-weight: bold;
|
||||
|
||||
&[data-state='on'] {
|
||||
color: var(--bg-robin-200);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--bg-robin-500);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionToggleSelectorWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
|
||||
--divider-color: var(--l2-border);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { PermissionScope } from '../../types';
|
||||
import { getResourcePanel } from '../../permissions.config';
|
||||
import ItemInputSelector from './ItemInputSelector';
|
||||
|
||||
import styles from './ActionToggle.module.scss';
|
||||
import { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
|
||||
import { getActionLabel } from 'container/RolesSettings/ViewRolePage/components/permissionDisplay.utils';
|
||||
|
||||
const SCOPE_LABELS: Record<PermissionScope, string> = {
|
||||
[PermissionScope.NONE]: 'None',
|
||||
[PermissionScope.ALL]: 'All',
|
||||
[PermissionScope.ONLY_SELECTED]: 'Only selected',
|
||||
};
|
||||
|
||||
interface ActionToggleProps {
|
||||
action: AuthZVerb;
|
||||
scope: string;
|
||||
selectedIds: string[];
|
||||
resource: AuthZResource;
|
||||
canSelectIndividually: boolean;
|
||||
onScopeChange: (scope: PermissionScope) => void;
|
||||
onSelectedIdsChange: (ids: string[]) => void;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
function ActionToggle({
|
||||
action,
|
||||
scope,
|
||||
selectedIds,
|
||||
resource,
|
||||
canSelectIndividually,
|
||||
onScopeChange,
|
||||
onSelectedIdsChange,
|
||||
hasError = false,
|
||||
}: ActionToggleProps): JSX.Element {
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [pendingScope, setPendingScope] = useState<PermissionScope | null>(null);
|
||||
|
||||
const displayLabel = getActionLabel(action);
|
||||
|
||||
const scopeItems: Array<{ value: PermissionScope; label: string }> =
|
||||
useMemo(() => {
|
||||
const items = [
|
||||
{ value: PermissionScope.NONE, label: SCOPE_LABELS[PermissionScope.NONE] },
|
||||
{ value: PermissionScope.ALL, label: SCOPE_LABELS[PermissionScope.ALL] },
|
||||
];
|
||||
if (canSelectIndividually) {
|
||||
items.push({
|
||||
value: PermissionScope.ONLY_SELECTED,
|
||||
label: SCOPE_LABELS[PermissionScope.ONLY_SELECTED],
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [canSelectIndividually]);
|
||||
|
||||
const handleToggleChange = useCallback(
|
||||
(value: string): void => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isLeavingOnlySelected =
|
||||
scope === PermissionScope.ONLY_SELECTED &&
|
||||
value !== PermissionScope.ONLY_SELECTED;
|
||||
const hasSelectedItems = selectedIds.length > 0;
|
||||
|
||||
if (isLeavingOnlySelected && hasSelectedItems) {
|
||||
setPendingScope(value as PermissionScope);
|
||||
setConfirmDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onScopeChange(value as PermissionScope);
|
||||
},
|
||||
[scope, selectedIds.length, onScopeChange],
|
||||
);
|
||||
|
||||
const handleConfirmScopeChange = useCallback((): void => {
|
||||
if (pendingScope) {
|
||||
onSelectedIdsChange([]);
|
||||
onScopeChange(pendingScope);
|
||||
}
|
||||
setConfirmDialogOpen(false);
|
||||
setPendingScope(null);
|
||||
}, [pendingScope, onSelectedIdsChange, onScopeChange]);
|
||||
|
||||
const handleCancelScopeChange = useCallback((): void => {
|
||||
setConfirmDialogOpen(false);
|
||||
setPendingScope(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={styles.actionToggle}
|
||||
data-testid={`action-toggle-${resource}-${action}`}
|
||||
>
|
||||
<div className={styles.actionToggleHeader}>
|
||||
<Typography as="span" size="base">
|
||||
{displayLabel}
|
||||
</Typography>
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
size="sm"
|
||||
value={scope}
|
||||
onChange={handleToggleChange}
|
||||
items={scopeItems}
|
||||
className={styles.actionToggleScopeToggle}
|
||||
testId={`action-toggle-scope-${resource}-${action}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{scope === PermissionScope.ONLY_SELECTED && (
|
||||
<div className={styles.actionToggleSelectorWrapper}>
|
||||
<Divider />
|
||||
|
||||
<ItemInputSelector
|
||||
placeholder={getResourcePanel(resource).selectorPlaceholder}
|
||||
selectedIds={selectedIds}
|
||||
onChange={onSelectedIdsChange}
|
||||
docsAnchor={getResourcePanel(resource).docsAnchor}
|
||||
hasError={hasError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDialogOpen}
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
handleCancelScopeChange();
|
||||
}
|
||||
}}
|
||||
title="Change permission scope?"
|
||||
confirmText="Change scope"
|
||||
cancelText="Cancel"
|
||||
onConfirm={handleConfirmScopeChange}
|
||||
onCancel={handleCancelScopeChange}
|
||||
>
|
||||
<Typography>
|
||||
You have {selectedIds.length} item{selectedIds.length > 1 ? 's' : ''}{' '}
|
||||
selected. Changing the scope will clear your current items.
|
||||
<br />
|
||||
<br />
|
||||
Don't worry, this doesn't update this role yet, it only confirms
|
||||
that you want to clear the items.
|
||||
</Typography>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionToggle;
|
||||
@@ -0,0 +1,105 @@
|
||||
.itemInputSelector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
padding: var(--spacing-4);
|
||||
|
||||
--input-suffix-padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
.itemInputSelectorError {
|
||||
border-color: var(--destructive);
|
||||
}
|
||||
|
||||
.itemInputSelectorFooter {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-3);
|
||||
padding-top: var(--spacing-3);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.itemInputSelectorBadges {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
max-height: 60px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.itemInputSelectorInfoIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.itemInputSelectorBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 140px;
|
||||
padding: 2px 4px 2px 6px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.itemInputSelectorBadgeLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemInputSelectorBadgeRemove {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.itemInputSelectorHint {
|
||||
margin: 0;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Info, Plus, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ItemInputSelector.module.scss';
|
||||
|
||||
const BASE_DOCS_URL =
|
||||
'https://signoz.io/docs/manage/administrator-guide/iam/permissions/';
|
||||
|
||||
export interface ItemInputSelectorProps {
|
||||
placeholder: string;
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
docsAnchor?: string;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
function parseInputValues(input: string): string[] {
|
||||
return input
|
||||
.split(/[\s,]+/)
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function ItemInputSelector({
|
||||
placeholder,
|
||||
selectedIds,
|
||||
onChange,
|
||||
docsAnchor = 'role',
|
||||
hasError = false,
|
||||
}: ItemInputSelectorProps): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const footerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const addValues = useCallback(
|
||||
(input: string): void => {
|
||||
const values = parseInputValues(input);
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingSet = new Set(selectedIds);
|
||||
const newIds = values.filter((v) => !existingSet.has(v));
|
||||
|
||||
if (newIds.length > 0) {
|
||||
onChange([...selectedIds, ...newIds]);
|
||||
}
|
||||
|
||||
setInputValue('');
|
||||
},
|
||||
[selectedIds, onChange],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setInputValue(e.target.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addValues(inputValue);
|
||||
}
|
||||
},
|
||||
[inputValue, addValues],
|
||||
);
|
||||
|
||||
const handleInputBlur = useCallback((): void => {
|
||||
addValues(inputValue);
|
||||
}, [inputValue, addValues]);
|
||||
|
||||
const handleAddClick = useCallback((): void => {
|
||||
addValues(inputValue);
|
||||
}, [inputValue, addValues]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(itemId: string): void => {
|
||||
onChange(selectedIds.filter((id) => id !== itemId));
|
||||
},
|
||||
[selectedIds, onChange],
|
||||
);
|
||||
|
||||
const handleBadgeKeyDown = useCallback(
|
||||
(
|
||||
e: React.KeyboardEvent<HTMLButtonElement>,
|
||||
itemId: string,
|
||||
index: number,
|
||||
): void => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
handleRemove(itemId);
|
||||
|
||||
const targetIndex = index > 0 ? index - 1 : 0;
|
||||
requestAnimationFrame(() => {
|
||||
const buttons = footerRef.current?.querySelectorAll('button');
|
||||
const targetButton = buttons?.[targetIndex] as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
targetButton?.focus();
|
||||
});
|
||||
},
|
||||
[handleRemove],
|
||||
);
|
||||
|
||||
const showError = hasError && selectedIds.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
styles.itemInputSelector,
|
||||
showError ? styles.itemInputSelectorError : '',
|
||||
)}
|
||||
data-testid="item-input-selector"
|
||||
>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
data-testid="item-input-selector-input"
|
||||
suffix={
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
onClick={handleAddClick}
|
||||
disabled={!inputValue.trim()}
|
||||
data-testid="item-input-selector-add-btn"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{selectedIds.length > 0 ? (
|
||||
<div ref={footerRef} className={styles.itemInputSelectorFooter}>
|
||||
<div className={styles.itemInputSelectorBadges}>
|
||||
{selectedIds.map((id, index) => (
|
||||
<span key={id} className={styles.itemInputSelectorBadge} title={id}>
|
||||
<Typography
|
||||
as="span"
|
||||
size="small"
|
||||
truncate={1}
|
||||
className={styles.itemInputSelectorBadgeLabel}
|
||||
>
|
||||
{id}
|
||||
</Typography>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.itemInputSelectorBadgeRemove}
|
||||
onClick={(): void => handleRemove(id)}
|
||||
onKeyDown={(e): void => handleBadgeKeyDown(e, id, index)}
|
||||
aria-label={`Remove ${id}`}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<TooltipSimple
|
||||
title={
|
||||
<Typography align="left">
|
||||
Still not sure on how to add selectors? <br />
|
||||
<Typography.Link
|
||||
href={`${BASE_DOCS_URL}#${docsAnchor}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Check the docs
|
||||
</Typography.Link>{' '}
|
||||
to understand selectors for this resource.
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
<Info size={16} className={styles.itemInputSelectorInfoIcon} />
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
) : (
|
||||
<Typography className={styles.itemInputSelectorHint}>
|
||||
Not sure what to type here?{' '}
|
||||
<Typography.Link
|
||||
href={`${BASE_DOCS_URL}#${docsAnchor}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Check the docs
|
||||
</Typography.Link>{' '}
|
||||
to understand selectors for this resource.
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemInputSelector;
|
||||
@@ -0,0 +1,44 @@
|
||||
.jsonEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.jsonEditorContainer {
|
||||
position: relative;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 24px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.jsonEditorErrorWrapper {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.jsonEditorError {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
|
||||
border: 1px solid var(--danger-background);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.jsonEditorErrorMessage {
|
||||
font-family:
|
||||
Geist Mono,
|
||||
monospace;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import MEditor, { Monaco, OnMount } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { AuthtypesTransactionGroupDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import {
|
||||
defineJsonTheme,
|
||||
EDITABLE_EDITOR_OPTIONS,
|
||||
JSON_THEME_DARK,
|
||||
} from '../../monaco.config';
|
||||
import {
|
||||
transformResourcePermissionsToTransactionGroups,
|
||||
transformTransactionGroupsToResourcePermissions,
|
||||
} from '../../hooks/useRolePermissions';
|
||||
import {
|
||||
registerCompletionProvider,
|
||||
registerJsonSchema,
|
||||
ROLE_PERMISSIONS_MODEL_PATH,
|
||||
} from './jsonSchema.config';
|
||||
|
||||
import styles from './JsonEditor.module.scss';
|
||||
import { JsonEditorProps, JsonEditorRef } from './JsonEditor.types';
|
||||
|
||||
type MonacoEditor = Parameters<OnMount>[0];
|
||||
|
||||
const JsonEditor = forwardRef<JsonEditorRef, JsonEditorProps>(
|
||||
function JsonEditor({ resources, mode, onChange, onValidityChange }, ref) {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [copyState, copyToClipboard] = useCopyToClipboard();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [schemaErrors, setSchemaErrors] = useState<string[]>([]);
|
||||
const [jsonBuffer, setJsonBuffer] = useState<string>(() => {
|
||||
const transactionGroups =
|
||||
transformResourcePermissionsToTransactionGroups(resources);
|
||||
return JSON.stringify(transactionGroups, null, 2);
|
||||
});
|
||||
const prevModeRef = useRef(mode);
|
||||
const completionDisposableRef = useRef<{ dispose(): void } | null>(null);
|
||||
const editorRef = useRef<MonacoEditor | null>(null);
|
||||
const markersListenerRef = useRef<{ dispose(): void } | null>(null);
|
||||
|
||||
const hasError = parseError !== null || schemaErrors.length > 0;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
hasParseError: (): boolean => hasError,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
onValidityChange?.(hasError);
|
||||
}, [hasError, onValidityChange]);
|
||||
|
||||
// Reinitialize buffer when switching from interactive to json mode
|
||||
useEffect(() => {
|
||||
const wasInteractive = prevModeRef.current === 'interactive';
|
||||
const isNowJson = mode === 'json';
|
||||
|
||||
if (wasInteractive && isNowJson) {
|
||||
const transactionGroups =
|
||||
transformResourcePermissionsToTransactionGroups(resources);
|
||||
setJsonBuffer(JSON.stringify(transactionGroups, null, 2));
|
||||
setParseError(null);
|
||||
}
|
||||
|
||||
prevModeRef.current = mode;
|
||||
}, [mode, resources]);
|
||||
|
||||
const handleEditorChange = useCallback(
|
||||
(value: string | undefined): void => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
setJsonBuffer(value);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as AuthtypesTransactionGroupDTO[];
|
||||
const resourcePermissions =
|
||||
transformTransactionGroupsToResourcePermissions(parsed);
|
||||
setParseError(null);
|
||||
onChange(resourcePermissions);
|
||||
} catch (err) {
|
||||
setParseError(err instanceof Error ? err.message : 'Invalid JSON format');
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const configureMonaco = useCallback((monaco: Monaco): void => {
|
||||
defineJsonTheme(monaco);
|
||||
registerJsonSchema(monaco);
|
||||
completionDisposableRef.current = registerCompletionProvider(monaco);
|
||||
}, []);
|
||||
|
||||
const handleEditorMount: OnMount = useCallback((editorInstance, monaco) => {
|
||||
editorRef.current = editorInstance;
|
||||
|
||||
type MonacoMarker = ReturnType<typeof monaco.editor.getModelMarkers>[number];
|
||||
|
||||
markersListenerRef.current = monaco.editor.onDidChangeMarkers(
|
||||
(uris: readonly Parameters<typeof monaco.Uri.parse>[0][]) => {
|
||||
const model = editorInstance.getModel();
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelUri = model.uri.toString();
|
||||
const hasRelevantChange = uris.some((uri) => uri.toString() === modelUri);
|
||||
if (!hasRelevantChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
|
||||
const errors = markers
|
||||
.filter(
|
||||
(marker: MonacoMarker) =>
|
||||
marker.severity === monaco.MarkerSeverity.Error,
|
||||
)
|
||||
.map(
|
||||
(marker: MonacoMarker) =>
|
||||
`Line ${marker.startLineNumber}: ${marker.message}`,
|
||||
);
|
||||
|
||||
setSchemaErrors(errors);
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
completionDisposableRef.current?.dispose();
|
||||
markersListenerRef.current?.dispose();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState.value) {
|
||||
setCopied(true);
|
||||
const timer = setTimeout(() => setCopied(false), 1500);
|
||||
return (): void => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [copyState]);
|
||||
|
||||
const handleCopy = useCallback((): void => {
|
||||
copyToClipboard(jsonBuffer);
|
||||
}, [copyToClipboard, jsonBuffer]);
|
||||
|
||||
return (
|
||||
<div className={styles.jsonEditor} data-testid="json-editor">
|
||||
<div className={styles.jsonEditorContainer}>
|
||||
<TooltipSimple title={copied ? 'Copied!' : 'Copy JSON'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.copyButton}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={14} color={Color.BG_FOREST_400} />
|
||||
) : (
|
||||
<Copy
|
||||
size={14}
|
||||
color={isDarkMode ? Color.BG_VANILLA_400 : Color.TEXT_INK_400}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<MEditor
|
||||
value={jsonBuffer}
|
||||
language="json"
|
||||
path={ROLE_PERMISSIONS_MODEL_PATH}
|
||||
options={EDITABLE_EDITOR_OPTIONS}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorMount}
|
||||
height="100%"
|
||||
theme={isDarkMode ? JSON_THEME_DARK : 'light'}
|
||||
beforeMount={configureMonaco}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.jsonEditorErrorWrapper}>
|
||||
{parseError && (
|
||||
<div className={styles.jsonEditorError} data-testid="json-editor-error">
|
||||
<Typography as="span" size="base" weight="medium">
|
||||
Parse Error:
|
||||
</Typography>
|
||||
<Typography
|
||||
as="span"
|
||||
size="base"
|
||||
className={styles.jsonEditorErrorMessage}
|
||||
>
|
||||
{parseError}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
{!parseError && schemaErrors.length > 0 && (
|
||||
<div
|
||||
className={styles.jsonEditorError}
|
||||
data-testid="json-editor-schema-error"
|
||||
>
|
||||
<Typography as="span" size="base" weight="medium">
|
||||
Schema Error:
|
||||
</Typography>
|
||||
<Typography
|
||||
as="span"
|
||||
size="base"
|
||||
className={styles.jsonEditorErrorMessage}
|
||||
>
|
||||
{schemaErrors[0]}
|
||||
{schemaErrors.length > 1 && ` (+${schemaErrors.length - 1} more)`}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default JsonEditor;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ResourcePermissions } from '../../types';
|
||||
|
||||
export type EditorMode = 'interactive' | 'json';
|
||||
|
||||
export interface JsonEditorProps {
|
||||
resources: ResourcePermissions[];
|
||||
mode: EditorMode;
|
||||
onChange: (resources: ResourcePermissions[]) => void;
|
||||
onValidityChange?: (hasError: boolean) => void;
|
||||
}
|
||||
|
||||
export interface JsonEditorRef {
|
||||
hasParseError: () => boolean;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
.permissionEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.permissionEditorHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.permissionEditorTitle {
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.permissionEditorDivider {
|
||||
height: 7px;
|
||||
flex: 1 1 0%;
|
||||
border-width: medium medium;
|
||||
border-style: none none;
|
||||
border-color: currentcolor currentcolor;
|
||||
border-image: initial;
|
||||
border-top: 2px dotted var(--l1-border);
|
||||
border-bottom: 2px dotted var(--l1-border);
|
||||
margin: 0px var(--spacing-4);
|
||||
}
|
||||
|
||||
.permissionEditorModeToggle {
|
||||
display: inline-flex;
|
||||
grid-auto-flow: column;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.permissionEditorModeItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: var(--spacing-3) var(--spacing-6);
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.permissionEditorModeInput {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
* {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[data-state='checked'] + label {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.permissionEditorContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.permissionEditorResourceList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
padding-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.permissionEditorCollapsedSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.permissionEditorCollapsedHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
background: var(--l3-background);
|
||||
border: 1px dashed var(--l2-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.permissionEditorCollapsedLabel {
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l2-foreground);
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.permissionEditorCollapsedCount {
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.permissionEditorCollapseAction {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { SolidAlertTriangle } from '@signozhq/icons';
|
||||
import { Button, ButtonGroup } from '@signozhq/ui/button';
|
||||
import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Skeleton } from 'antd';
|
||||
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
|
||||
|
||||
import { PermissionScope, ResourcePermissions } from '../../types';
|
||||
import type { EditorMode, JsonEditorRef } from './JsonEditor.types';
|
||||
import JsonEditor from './JsonEditor';
|
||||
import ResourceCard from './ResourceCard';
|
||||
|
||||
import styles from './PermissionEditor.module.scss';
|
||||
|
||||
interface PermissionEditorProps {
|
||||
resources: ResourcePermissions[];
|
||||
mode: EditorMode;
|
||||
onModeChange: (mode: EditorMode) => void;
|
||||
onResourceChange: (resources: ResourcePermissions[]) => void;
|
||||
onJsonValidityChange?: (hasError: boolean) => void;
|
||||
isLoading?: boolean;
|
||||
validationErrors?: Set<string>;
|
||||
}
|
||||
|
||||
function PermissionEditor({
|
||||
resources,
|
||||
mode,
|
||||
onModeChange,
|
||||
onResourceChange,
|
||||
onJsonValidityChange,
|
||||
isLoading = false,
|
||||
validationErrors,
|
||||
}: PermissionEditorProps): JSX.Element {
|
||||
const jsonEditorRef = useRef<JsonEditorRef>(null);
|
||||
|
||||
const handleJsonValidityChange = useCallback(
|
||||
(hasError: boolean): void => {
|
||||
onJsonValidityChange?.(mode === 'json' && hasError);
|
||||
},
|
||||
[mode, onJsonValidityChange],
|
||||
);
|
||||
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
|
||||
const [expandedResources, setExpandedResources] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const handleExpandAll = useCallback((): void => {
|
||||
setExpandedResources(new Set(resources.map((r) => r.resourceId)));
|
||||
}, [resources]);
|
||||
|
||||
const handleCollapseAll = useCallback((): void => {
|
||||
setExpandedResources(new Set());
|
||||
}, []);
|
||||
|
||||
const handleExpandChange = useCallback(
|
||||
(resourceId: string) =>
|
||||
(expanded: boolean): void => {
|
||||
setExpandedResources((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (expanded) {
|
||||
next.add(resourceId);
|
||||
} else {
|
||||
next.delete(resourceId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleActionChange = useCallback(
|
||||
(
|
||||
resourceId: AuthZResource,
|
||||
action: AuthZVerb,
|
||||
scope: PermissionScope,
|
||||
selectedIds: string[],
|
||||
): void => {
|
||||
const updatedResources = resources.map((r) => {
|
||||
if (r.resourceId !== resourceId) {
|
||||
return r;
|
||||
}
|
||||
return {
|
||||
...r,
|
||||
actions: {
|
||||
...r.actions,
|
||||
[action]: {
|
||||
scope: scope,
|
||||
selectedIds,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
onResourceChange(updatedResources);
|
||||
},
|
||||
[resources, onResourceChange],
|
||||
);
|
||||
|
||||
const handleJsonChange = useCallback(
|
||||
(updatedResources: ResourcePermissions[]): void => {
|
||||
onResourceChange(updatedResources);
|
||||
},
|
||||
[onResourceChange],
|
||||
);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(value: string): void => {
|
||||
const newMode = value as EditorMode;
|
||||
|
||||
if (
|
||||
newMode === 'interactive' &&
|
||||
mode === 'json' &&
|
||||
jsonEditorRef.current?.hasParseError()
|
||||
) {
|
||||
setShowDiscardConfirm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newMode === 'interactive') {
|
||||
onJsonValidityChange?.(false);
|
||||
}
|
||||
|
||||
onModeChange(newMode);
|
||||
},
|
||||
[mode, onModeChange, onJsonValidityChange],
|
||||
);
|
||||
|
||||
const handleDiscardConfirm = useCallback(async (): Promise<boolean> => {
|
||||
onJsonValidityChange?.(false);
|
||||
onModeChange('interactive');
|
||||
setShowDiscardConfirm(false);
|
||||
return true;
|
||||
}, [onModeChange, onJsonValidityChange]);
|
||||
|
||||
const handleDiscardCancel = useCallback((): void => {
|
||||
setShowDiscardConfirm(false);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.permissionEditor}>
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.permissionEditor} data-testid="permission-editor">
|
||||
<div className={styles.permissionEditorHeader}>
|
||||
<Typography
|
||||
as="span"
|
||||
size="small"
|
||||
weight="medium"
|
||||
color="muted"
|
||||
className={styles.permissionEditorTitle}
|
||||
>
|
||||
Transaction Groups
|
||||
</Typography>
|
||||
<hr className={styles.permissionEditorDivider} />
|
||||
<RadioGroup
|
||||
className={styles.permissionEditorModeToggle}
|
||||
value={mode}
|
||||
onChange={handleModeChange}
|
||||
testId="permission-editor-mode"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="interactive"
|
||||
containerClassName={styles.permissionEditorModeItem}
|
||||
className={styles.permissionEditorModeInput}
|
||||
testId="permission-editor-mode-interactive"
|
||||
>
|
||||
Interactive
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
value="json"
|
||||
containerClassName={styles.permissionEditorModeItem}
|
||||
className={styles.permissionEditorModeInput}
|
||||
testId="permission-editor-mode-json"
|
||||
>
|
||||
JSON
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className={styles.permissionEditorContent}>
|
||||
{mode === 'interactive' ? (
|
||||
<>
|
||||
<div className={styles.permissionEditorCollapseAction}>
|
||||
<ButtonGroup
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
testId="toggle-all-group"
|
||||
>
|
||||
<Button onClick={handleExpandAll} data-testid="expand-all-button">
|
||||
Expand all
|
||||
</Button>
|
||||
<Button onClick={handleCollapseAll} data-testid="collapse-all-button">
|
||||
Collapse all
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div className={styles.permissionEditorResourceList}>
|
||||
{resources.map((resource) => (
|
||||
<ResourceCard
|
||||
key={resource.resourceId}
|
||||
resource={resource}
|
||||
onActionChange={handleActionChange}
|
||||
isExpanded={expandedResources.has(resource.resourceId)}
|
||||
onExpandChange={handleExpandChange(resource.resourceId)}
|
||||
validationErrors={validationErrors}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<JsonEditor
|
||||
ref={jsonEditorRef}
|
||||
resources={resources}
|
||||
mode={mode}
|
||||
onChange={handleJsonChange}
|
||||
onValidityChange={handleJsonValidityChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDiscardConfirm}
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
handleDiscardCancel();
|
||||
}
|
||||
}}
|
||||
title="Discard JSON changes?"
|
||||
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
|
||||
confirmText="Discard"
|
||||
confirmColor="destructive"
|
||||
cancelText="Stay in JSON"
|
||||
onConfirm={handleDiscardConfirm}
|
||||
onCancel={handleDiscardCancel}
|
||||
>
|
||||
<Typography>
|
||||
The JSON contains errors and cannot be parsed. Switching to Interactive
|
||||
mode will discard your changes.
|
||||
</Typography>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionEditor;
|
||||
@@ -0,0 +1,64 @@
|
||||
.resourceCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: var(--l2-background);
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.resourceCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--l1-background-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.resourceCardHeaderLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.resourceCardChevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resourceCardHeaderRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.resourceCardBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
height: 1px;
|
||||
margin: 0 var(--spacing-8);
|
||||
background: var(--l2-border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
|
||||
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { useRoleGrantedCount } from '../../hooks/useRoleGrantedCount';
|
||||
import { supportsOnlySelected } from '../../permissions.config';
|
||||
import ActionToggle from './ActionToggle';
|
||||
|
||||
import styles from './ResourceCard.module.scss';
|
||||
import { PermissionScope, ResourcePermissions } from '../../types';
|
||||
|
||||
interface ResourceCardProps {
|
||||
resource: ResourcePermissions;
|
||||
onActionChange: (
|
||||
resourceId: AuthZResource,
|
||||
action: AuthZVerb,
|
||||
scope: PermissionScope,
|
||||
selectedIds: string[],
|
||||
) => void;
|
||||
defaultExpanded?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onExpandChange?: (expanded: boolean) => void;
|
||||
validationErrors?: Set<string>;
|
||||
}
|
||||
|
||||
function ResourceCard({
|
||||
resource,
|
||||
onActionChange,
|
||||
defaultExpanded = false,
|
||||
isExpanded: controlledExpanded,
|
||||
onExpandChange,
|
||||
validationErrors,
|
||||
}: ResourceCardProps): JSX.Element {
|
||||
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
|
||||
const isControlled = controlledExpanded !== undefined;
|
||||
const isExpanded = isControlled ? controlledExpanded : internalExpanded;
|
||||
|
||||
const handleToggleExpand = useCallback((): void => {
|
||||
if (isControlled) {
|
||||
onExpandChange?.(!controlledExpanded);
|
||||
} else {
|
||||
setInternalExpanded((prev) => !prev);
|
||||
}
|
||||
}, [isControlled, controlledExpanded, onExpandChange]);
|
||||
|
||||
const handleScopeChange = useCallback(
|
||||
(action: AuthZVerb) =>
|
||||
(scope: PermissionScope): void => {
|
||||
const currentConfig = resource.actions[action];
|
||||
const selectedIds =
|
||||
scope === PermissionScope.ONLY_SELECTED
|
||||
? (currentConfig?.selectedIds ?? [])
|
||||
: [];
|
||||
onActionChange(resource.resourceId, action, scope, selectedIds);
|
||||
},
|
||||
[resource.resourceId, resource.actions, onActionChange],
|
||||
);
|
||||
|
||||
const handleSelectedIdsChange = useCallback(
|
||||
(action: AuthZVerb) =>
|
||||
(ids: string[]): void => {
|
||||
const currentConfig = resource.actions[action];
|
||||
onActionChange(
|
||||
resource.resourceId,
|
||||
action,
|
||||
currentConfig?.scope ?? PermissionScope.ONLY_SELECTED,
|
||||
ids,
|
||||
);
|
||||
},
|
||||
[resource.resourceId, resource.actions, onActionChange],
|
||||
);
|
||||
|
||||
const [grantedCount, totalCount] = useRoleGrantedCount(resource);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.resourceCard}
|
||||
data-testid={`resource-card-${resource.resourceId}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.resourceCardHeader}
|
||||
onClick={handleToggleExpand}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${resource.resourceLabel}: ${grantedCount} of ${totalCount} permissions granted`}
|
||||
data-testid={`resource-card-header-${resource.resourceId}`}
|
||||
>
|
||||
<div className={styles.resourceCardHeaderLeft}>
|
||||
<span className={styles.resourceCardChevron}>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</span>
|
||||
<Typography as="span" size="base" weight="medium">
|
||||
{resource.resourceLabel}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.resourceCardHeaderRight}>
|
||||
<Typography as="span" size="base" color="muted">
|
||||
{grantedCount} / {totalCount} granted
|
||||
</Typography>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className={styles.resourceCardBody}>
|
||||
{resource.availableActions.map((action) => {
|
||||
const actionConfig = resource.actions[action] ?? {
|
||||
scope: PermissionScope.NONE,
|
||||
selectedIds: [],
|
||||
};
|
||||
return (
|
||||
<ActionToggle
|
||||
key={action}
|
||||
action={action}
|
||||
scope={actionConfig.scope}
|
||||
selectedIds={actionConfig.selectedIds}
|
||||
resource={resource.resourceId}
|
||||
canSelectIndividually={supportsOnlySelected(action)}
|
||||
onScopeChange={handleScopeChange(action)}
|
||||
onSelectedIdsChange={handleSelectedIdsChange(action)}
|
||||
hasError={validationErrors?.has(`${resource.resourceId}:${action}`)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResourceCard;
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
ROLE_PERMISSIONS_MODEL_PATH,
|
||||
shouldProvideCompletions,
|
||||
} from '../jsonSchema.config';
|
||||
|
||||
describe('shouldProvideCompletions', () => {
|
||||
const validPath = `/some/path/${ROLE_PERMISSIONS_MODEL_PATH}`;
|
||||
const invalidPath = '/some/other/file.json';
|
||||
|
||||
describe('model path validation', () => {
|
||||
it('returns false when model path does not end with ROLE_PERMISSIONS_MODEL_PATH', () => {
|
||||
expect(shouldProvideCompletions(invalidPath, '[')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when model path ends with ROLE_PERMISSIONS_MODEL_PATH and at array position', () => {
|
||||
expect(shouldProvideCompletions(validPath, '[')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursor position validation', () => {
|
||||
it('returns true when cursor is after opening bracket', () => {
|
||||
expect(shouldProvideCompletions(validPath, '[')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when cursor is after comma at root level', () => {
|
||||
expect(shouldProvideCompletions(validPath, '[{},\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true with whitespace before bracket', () => {
|
||||
expect(shouldProvideCompletions(validPath, ' \n [')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true with whitespace before comma', () => {
|
||||
expect(shouldProvideCompletions(validPath, '[{} , ')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when cursor is in middle of text', () => {
|
||||
expect(shouldProvideCompletions(validPath, '[{"foo"')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when cursor is after closing bracket', () => {
|
||||
expect(shouldProvideCompletions(validPath, '[]')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when cursor is after colon', () => {
|
||||
expect(shouldProvideCompletions(validPath, '[{"key":')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('brace depth validation', () => {
|
||||
it('returns false when cursor is inside an object', () => {
|
||||
expect(shouldProvideCompletions(validPath, '[{')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when cursor is inside nested object', () => {
|
||||
const text = '[{"objectGroup": {"resource": {';
|
||||
expect(shouldProvideCompletions(validPath, text)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when all objects are closed and at comma', () => {
|
||||
const text = '[{"objectGroup": {"resource": {}}}],';
|
||||
expect(shouldProvideCompletions(validPath, text)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true after complete object with comma', () => {
|
||||
const text = `[{
|
||||
"objectGroup": {
|
||||
"resource": { "kind": "dashboard", "type": "object" },
|
||||
"selectors": ["*"]
|
||||
},
|
||||
"relation": "read"
|
||||
},`;
|
||||
expect(shouldProvideCompletions(validPath, text)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false inside partial object after comma', () => {
|
||||
const text = `[{
|
||||
"objectGroup": {
|
||||
"resource": { "kind": "dashboard", "type": "object" },`;
|
||||
expect(shouldProvideCompletions(validPath, text)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty string', () => {
|
||||
expect(shouldProvideCompletions(validPath, '')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles only whitespace', () => {
|
||||
expect(shouldProvideCompletions(validPath, ' \n\t ')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles unbalanced braces (more closing) - no completions for malformed JSON', () => {
|
||||
expect(shouldProvideCompletions(validPath, '[{}}},')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { Monaco } from '@monaco-editor/react';
|
||||
import permissionsType from 'hooks/useAuthZ/permissions.type';
|
||||
import transactionGroupSchema from 'schemas/generated/transactionGroups.schema.json';
|
||||
|
||||
export const TRANSACTION_GROUP_SCHEMA = transactionGroupSchema;
|
||||
|
||||
const SCHEMA_URI = 'inmemory://model/transaction-groups-schema.json';
|
||||
export const ROLE_PERMISSIONS_MODEL_PATH = 'role-permissions.json';
|
||||
|
||||
export function registerJsonSchema(monaco: Monaco): void {
|
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
||||
validate: true,
|
||||
schemaValidation: 'error',
|
||||
schemas: [
|
||||
{
|
||||
uri: SCHEMA_URI,
|
||||
fileMatch: [ROLE_PERMISSIONS_MODEL_PATH],
|
||||
schema: TRANSACTION_GROUP_SCHEMA,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
interface SnippetDef {
|
||||
label: string;
|
||||
insertText: string;
|
||||
documentation: string;
|
||||
}
|
||||
|
||||
type BasePermissionTypeDataResourcesType =
|
||||
(typeof permissionsType.data)['resources'][number];
|
||||
|
||||
function createGrantAllPermissionSnippet(
|
||||
kind: BasePermissionTypeDataResourcesType['kind'],
|
||||
allowedVerbs: BasePermissionTypeDataResourcesType['allowedVerbs'],
|
||||
type: BasePermissionTypeDataResourcesType['type'],
|
||||
): SnippetDef {
|
||||
return {
|
||||
label: `${kind}:all`,
|
||||
insertText: allowedVerbs
|
||||
.map(
|
||||
(verb) => `{
|
||||
"objectGroup": {
|
||||
"resource": { "kind": "${kind}", "type": "${type}" },
|
||||
"selectors": ["*"]
|
||||
},
|
||||
"relation": "${verb}"
|
||||
}`,
|
||||
)
|
||||
.join(',\n'),
|
||||
documentation: `Grant all permissions (${allowedVerbs.join(', ')}) on ${kind}`,
|
||||
};
|
||||
}
|
||||
|
||||
function createGrantPermissionToVerbAndKind(
|
||||
kind: BasePermissionTypeDataResourcesType['kind'],
|
||||
verb: string,
|
||||
type: BasePermissionTypeDataResourcesType['type'],
|
||||
): SnippetDef {
|
||||
return {
|
||||
label: `${kind}:${verb}`,
|
||||
insertText: `{
|
||||
"objectGroup": {
|
||||
"resource": { "kind": "${kind}", "type": "${type}" },
|
||||
"selectors": ["*"]
|
||||
},
|
||||
"relation": "${verb}"
|
||||
}`,
|
||||
documentation: `${verb} permission on ${kind}`,
|
||||
};
|
||||
}
|
||||
|
||||
function createGrantPermissionAsReadonly(
|
||||
resources: (typeof permissionsType.data)['resources'],
|
||||
): SnippetDef {
|
||||
return {
|
||||
label: 'readonly',
|
||||
insertText: resources
|
||||
.filter(
|
||||
(r) => r.allowedVerbs.includes('read') || r.allowedVerbs.includes('list'),
|
||||
)
|
||||
.flatMap((r) => {
|
||||
const verbs = r.allowedVerbs.filter((v) => v === 'read' || v === 'list');
|
||||
return verbs.map(
|
||||
(verb) => `{
|
||||
"objectGroup": {
|
||||
"resource": { "kind": "${r.kind}", "type": "${r.type}" },
|
||||
"selectors": ["*"]
|
||||
},
|
||||
"relation": "${verb}"
|
||||
}`,
|
||||
);
|
||||
})
|
||||
.join(',\n'),
|
||||
documentation: 'Read-only access to all resources (read + list)',
|
||||
};
|
||||
}
|
||||
|
||||
function buildResourceSnippets(): SnippetDef[] {
|
||||
const { resources } = permissionsType.data;
|
||||
const snippets: SnippetDef[] = [];
|
||||
|
||||
for (const resource of resources) {
|
||||
const { kind, type, allowedVerbs } = resource;
|
||||
|
||||
snippets.push(createGrantAllPermissionSnippet(kind, allowedVerbs, type));
|
||||
|
||||
for (const verb of allowedVerbs) {
|
||||
snippets.push(createGrantPermissionToVerbAndKind(kind, verb, type));
|
||||
}
|
||||
}
|
||||
|
||||
snippets.push(createGrantPermissionAsReadonly(resources));
|
||||
|
||||
return snippets;
|
||||
}
|
||||
|
||||
const SNIPPETS = buildResourceSnippets();
|
||||
|
||||
type MonacoModel = Parameters<
|
||||
Parameters<
|
||||
Monaco['languages']['registerCompletionItemProvider']
|
||||
>[1]['provideCompletionItems']
|
||||
>[0];
|
||||
type MonacoPosition = Parameters<
|
||||
Parameters<
|
||||
Monaco['languages']['registerCompletionItemProvider']
|
||||
>[1]['provideCompletionItems']
|
||||
>[1];
|
||||
|
||||
interface Disposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if completions should be provided based on model path and cursor position.
|
||||
* Pure function for testability.
|
||||
*/
|
||||
export function shouldProvideCompletions(
|
||||
modelPath: string,
|
||||
textBeforeCursor: string,
|
||||
): boolean {
|
||||
if (!modelPath.endsWith(ROLE_PERMISSIONS_MODEL_PATH)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmed = textBeforeCursor.trim();
|
||||
const endsAtArrayPosition = trimmed.endsWith('[') || trimmed.endsWith(',');
|
||||
|
||||
if (!endsAtArrayPosition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let braceDepth = 0;
|
||||
for (const char of textBeforeCursor) {
|
||||
if (char === '{') {
|
||||
braceDepth++;
|
||||
} else if (char === '}') {
|
||||
braceDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
return braceDepth === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register completion provider for smart snippets.
|
||||
* Returns disposable to clean up on unmount.
|
||||
*/
|
||||
export function registerCompletionProvider(monaco: Monaco): Disposable {
|
||||
return monaco.languages.registerCompletionItemProvider('json', {
|
||||
triggerCharacters: ['"', '{', '['],
|
||||
provideCompletionItems(model: MonacoModel, position: MonacoPosition) {
|
||||
const textBeforeCursor = model.getValueInRange({
|
||||
startLineNumber: 1,
|
||||
startColumn: 1,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
});
|
||||
|
||||
if (!shouldProvideCompletions(model.uri.path, textBeforeCursor)) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
const suggestions = SNIPPETS.map((snippet, index) => ({
|
||||
label: snippet.label,
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText: snippet.insertText,
|
||||
insertTextRules:
|
||||
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: snippet.documentation,
|
||||
range,
|
||||
sortText: String(index).padStart(3, '0'),
|
||||
}));
|
||||
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './CreateEditRolePage';
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import type { ResourcePermissions } from '../types';
|
||||
import type { EditorMode } from './components/JsonEditor.types';
|
||||
import {
|
||||
createEmptyRolePermissions,
|
||||
useCreateRolePermissions,
|
||||
useRolePermissions,
|
||||
useUpdateRolePermissions,
|
||||
} from '../hooks/useRolePermissions';
|
||||
import { useRoleAuthZ } from '../hooks/useRoleAuthZ';
|
||||
import {
|
||||
useRoleUnsavedChanges,
|
||||
type RoleFormData,
|
||||
} from './useRoleUnsavedChanges';
|
||||
import { useRoleFormValidation } from './useRoleFormValidation';
|
||||
|
||||
const EDITOR_MODES: EditorMode[] = ['interactive', 'json'];
|
||||
|
||||
interface UseCreateEditRolePageCallbacksResult {
|
||||
formData: RoleFormData;
|
||||
setFormData: React.Dispatch<React.SetStateAction<RoleFormData>>;
|
||||
editorMode: EditorMode;
|
||||
setEditorMode: (mode: EditorMode) => void;
|
||||
resources: ResourcePermissions[];
|
||||
setResources: (resources: ResourcePermissions[]) => void;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
hasUnsavedChanges: boolean;
|
||||
handleSave: () => Promise<boolean>;
|
||||
handleCancel: () => void;
|
||||
handleFormChange: (field: keyof RoleFormData, value: string) => void;
|
||||
isCreateMode: boolean;
|
||||
loadError: APIError | null;
|
||||
saveError: APIError | null;
|
||||
clearSaveError: () => void;
|
||||
validationErrors: Set<string>;
|
||||
hasRequiredPermission: boolean;
|
||||
isAuthZLoading: boolean;
|
||||
deniedPermission: string;
|
||||
}
|
||||
|
||||
export function useCreateEditRolePageActions(
|
||||
roleId: string,
|
||||
roleName: string,
|
||||
): UseCreateEditRolePageCallbacksResult {
|
||||
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: '',
|
||||
});
|
||||
const [editorMode, setEditorMode] = useQueryState(
|
||||
'viewMode',
|
||||
parseAsStringLiteral(EDITOR_MODES).withDefault('interactive'),
|
||||
);
|
||||
const emptyResources = useMemo(() => createEmptyRolePermissions(), []);
|
||||
const [localResources, setLocalResources] = useState<ResourcePermissions[]>(
|
||||
() => (isCreateMode ? createEmptyRolePermissions() : []),
|
||||
);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [saveError, setSaveError] = useState<APIError | null>(null);
|
||||
|
||||
const { validationErrors, validateResources, clearValidationErrors } =
|
||||
useRoleFormValidation();
|
||||
|
||||
const {
|
||||
data: rolePermissionsData,
|
||||
isLoading: isLoadingPermissions,
|
||||
error: rolePermissionsError,
|
||||
} = useRolePermissions(roleId, {
|
||||
enabled: !isCreateMode,
|
||||
});
|
||||
|
||||
const loadError = rolePermissionsError
|
||||
? toAPIError(rolePermissionsError, 'Failed to load role')
|
||||
: null;
|
||||
|
||||
const { mutateAsync: createRole, isLoading: isCreating } =
|
||||
useCreateRolePermissions();
|
||||
const { mutateAsync: updateRole, isLoading: isUpdating } =
|
||||
useUpdateRolePermissions();
|
||||
const isSaving = isCreating || isUpdating;
|
||||
|
||||
useEffect(() => {
|
||||
if (rolePermissionsData && !isInitialized) {
|
||||
setFormData({
|
||||
name: rolePermissionsData.roleName,
|
||||
description: rolePermissionsData.roleDescription,
|
||||
});
|
||||
setLocalResources(JSON.parse(JSON.stringify(rolePermissionsData.resources)));
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [rolePermissionsData, isInitialized]);
|
||||
|
||||
const handleFormChange = useCallback(
|
||||
(field: keyof RoleFormData, value: string): void => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(mode: EditorMode): void => {
|
||||
void setEditorMode(mode);
|
||||
},
|
||||
[setEditorMode],
|
||||
);
|
||||
|
||||
const handleResourcesChange = useCallback(
|
||||
(resources: ResourcePermissions[]): void => {
|
||||
setLocalResources(resources);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const hasUnsavedChanges = useRoleUnsavedChanges(
|
||||
isCreateMode,
|
||||
formData,
|
||||
localResources,
|
||||
rolePermissionsData,
|
||||
emptyResources,
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<boolean> => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Role name is required', { position: 'bottom-center' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const validationError = validateResources(localResources);
|
||||
if (validationError) {
|
||||
setSaveError(
|
||||
new APIError({
|
||||
httpStatusCode: 400,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: validationError,
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
clearValidationErrors();
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
if (isCreateMode) {
|
||||
await createRole({
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
resources: localResources,
|
||||
});
|
||||
} else {
|
||||
await updateRole({
|
||||
roleId,
|
||||
description: formData.description,
|
||||
resources: localResources,
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
isCreateMode ? 'Role created successfully' : 'Role updated successfully',
|
||||
{ position: 'bottom-center' },
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setSaveError(
|
||||
toAPIError(
|
||||
error as ErrorType<RenderErrorResponseDTO>,
|
||||
'Failed to save role',
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}, [
|
||||
formData.name,
|
||||
formData.description,
|
||||
isCreateMode,
|
||||
roleId,
|
||||
localResources,
|
||||
createRole,
|
||||
updateRole,
|
||||
validateResources,
|
||||
clearValidationErrors,
|
||||
]);
|
||||
|
||||
const clearSaveError = useCallback((): void => {
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback((): void => {
|
||||
if (isCreateMode) {
|
||||
history.push(ROUTES.ROLES_SETTINGS);
|
||||
} else {
|
||||
const viewUrl = `${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}?name=${encodeURIComponent(roleName)}`;
|
||||
history.push(viewUrl);
|
||||
}
|
||||
}, [history, isCreateMode, roleId, roleName]);
|
||||
|
||||
return {
|
||||
formData,
|
||||
setFormData,
|
||||
editorMode,
|
||||
setEditorMode: handleModeChange,
|
||||
resources: localResources,
|
||||
setResources: handleResourcesChange,
|
||||
isLoading: isLoadingPermissions,
|
||||
isSaving,
|
||||
hasUnsavedChanges,
|
||||
handleSave,
|
||||
handleCancel,
|
||||
handleFormChange,
|
||||
isCreateMode,
|
||||
loadError,
|
||||
saveError,
|
||||
clearSaveError,
|
||||
validationErrors,
|
||||
hasRequiredPermission: isCreateMode
|
||||
? hasCreatePermission
|
||||
: hasReadPermission && hasUpdatePermission,
|
||||
isAuthZLoading,
|
||||
deniedPermission,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { PermissionScope, ResourcePermissions } from '../types';
|
||||
|
||||
interface UseRoleFormValidationResult {
|
||||
validationErrors: Set<string>;
|
||||
validateResources: (resources: ResourcePermissions[]) => string | null;
|
||||
clearValidationErrors: () => void;
|
||||
}
|
||||
|
||||
export function useRoleFormValidation(): UseRoleFormValidationResult {
|
||||
const [validationErrors, setValidationErrors] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const validateResources = useCallback(
|
||||
(resources: ResourcePermissions[]): string | null => {
|
||||
const errors = new Set<string>();
|
||||
|
||||
for (const resource of resources) {
|
||||
for (const [action, config] of Object.entries(resource.actions)) {
|
||||
if (
|
||||
config?.scope === PermissionScope.ONLY_SELECTED &&
|
||||
config.selectedIds.length === 0
|
||||
) {
|
||||
errors.add(`${resource.resourceId}:${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.size > 0) {
|
||||
setValidationErrors(errors);
|
||||
return 'Please add at least one selector for each "Only selected" permission.';
|
||||
}
|
||||
|
||||
setValidationErrors(new Set());
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearValidationErrors = useCallback((): void => {
|
||||
setValidationErrors(new Set());
|
||||
}, []);
|
||||
|
||||
return {
|
||||
validationErrors,
|
||||
validateResources,
|
||||
clearValidationErrors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ResourcePermissions } from '../types';
|
||||
|
||||
export interface RoleFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface RolePermissionsData {
|
||||
roleName: string;
|
||||
roleDescription: string;
|
||||
resources: ResourcePermissions[];
|
||||
}
|
||||
|
||||
export function useRoleUnsavedChanges(
|
||||
isCreateMode: boolean,
|
||||
formData: RoleFormData,
|
||||
localResources: ResourcePermissions[],
|
||||
rolePermissionsData: RolePermissionsData | undefined,
|
||||
emptyResources: ResourcePermissions[],
|
||||
): boolean {
|
||||
return useMemo(() => {
|
||||
if (isCreateMode) {
|
||||
return (
|
||||
formData.name.trim() !== '' ||
|
||||
formData.description.trim() !== '' ||
|
||||
JSON.stringify(localResources) !== JSON.stringify(emptyResources)
|
||||
);
|
||||
}
|
||||
|
||||
if (!rolePermissionsData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nameChanged = formData.name !== rolePermissionsData.roleName;
|
||||
const descriptionChanged =
|
||||
formData.description !== rolePermissionsData.roleDescription;
|
||||
const resourcesChanged =
|
||||
JSON.stringify(localResources) !==
|
||||
JSON.stringify(rolePermissionsData.resources);
|
||||
|
||||
return nameChanged || descriptionChanged || resourcesChanged;
|
||||
}, [
|
||||
isCreateMode,
|
||||
formData,
|
||||
localResources,
|
||||
rolePermissionsData,
|
||||
emptyResources,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.errorCallout {
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Trash2 } from '@signozhq/icons';
|
||||
import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import APIError from 'types/api/error';
|
||||
import styles from './DeleteRoleModal.module.scss';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
|
||||
interface DeleteRoleModalProps {
|
||||
isOpen: boolean;
|
||||
roleName: string;
|
||||
error: APIError | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
function DeleteRoleModal({
|
||||
isOpen,
|
||||
roleName,
|
||||
error,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteRoleModalProps): JSX.Element {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={isOpen}
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
title="Delete Role"
|
||||
titleIcon={<Trash2 size={14} />}
|
||||
confirmText="Delete Role"
|
||||
confirmColor="destructive"
|
||||
cancelText="Cancel"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
disableOutsideClick
|
||||
>
|
||||
<Typography>
|
||||
Are you sure you want to delete the role <strong>{roleName}</strong>? This
|
||||
action cannot be undone.
|
||||
</Typography>
|
||||
{error && (
|
||||
<Callout
|
||||
title="Failed to delete role"
|
||||
color="cherry"
|
||||
className={styles.errorCallout}
|
||||
>
|
||||
<ErrorInPlace error={error} height="auto" padding={0} />
|
||||
</Callout>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteRoleModal;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
invalidateGetRole,
|
||||
invalidateListRoles,
|
||||
useDeleteRole,
|
||||
} from 'api/generated/services/role';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
interface UseDeleteRoleModalProps {
|
||||
roleId?: string | null;
|
||||
isManaged: boolean;
|
||||
hasDeletePermission: boolean;
|
||||
onDeleteSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface UseDeleteRoleModalResult {
|
||||
isDeleteModalOpen: boolean;
|
||||
isDeleteDisabled: boolean;
|
||||
deleteDisabledReason: string;
|
||||
isDeleting: boolean;
|
||||
deleteError: APIError | null;
|
||||
handleOpenDeleteModal: () => void;
|
||||
handleCloseDeleteModal: () => void;
|
||||
handleConfirmDelete: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useDeleteRoleModal(
|
||||
props: UseDeleteRoleModalProps,
|
||||
): UseDeleteRoleModalResult {
|
||||
const { roleId, isManaged, hasDeletePermission, onDeleteSuccess } = props;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [deleteTargetRoleId, setDeleteTargetRoleId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<APIError | null>(null);
|
||||
|
||||
const { mutateAsync: deleteRole } = useDeleteRole();
|
||||
|
||||
const handleOpenDeleteModal = useCallback((): void => {
|
||||
setDeleteTargetRoleId(roleId ?? null);
|
||||
}, [roleId]);
|
||||
|
||||
const handleCloseDeleteModal = useCallback((): void => {
|
||||
setDeleteTargetRoleId(null);
|
||||
setDeleteError(null);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async (): Promise<boolean> => {
|
||||
if (!deleteTargetRoleId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
setDeleteError(null);
|
||||
|
||||
try {
|
||||
await deleteRole({ pathParams: { id: deleteTargetRoleId } });
|
||||
await invalidateListRoles(queryClient);
|
||||
await invalidateGetRole(queryClient, { id: deleteTargetRoleId });
|
||||
setDeleteTargetRoleId(null);
|
||||
onDeleteSuccess?.();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const apiError = convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO>,
|
||||
);
|
||||
setDeleteError(apiError ?? null);
|
||||
return false;
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [deleteRole, deleteTargetRoleId, queryClient, onDeleteSuccess]);
|
||||
|
||||
const isDeleteModalOpen = deleteTargetRoleId !== null;
|
||||
|
||||
const isDeleteDisabled = isManaged || !hasDeletePermission;
|
||||
const deleteDisabledReason = isManaged
|
||||
? 'Managed roles cannot be deleted'
|
||||
: 'You do not have permission to delete this role';
|
||||
|
||||
return {
|
||||
isDeleteModalOpen,
|
||||
isDeleteDisabled,
|
||||
deleteDisabledReason,
|
||||
isDeleting,
|
||||
deleteError,
|
||||
handleOpenDeleteModal,
|
||||
handleCloseDeleteModal,
|
||||
handleConfirmDelete,
|
||||
};
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
.permission-side-panel-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.permission-side-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 101;
|
||||
width: 720px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--l2-background);
|
||||
border-left: 1px solid var(--l1-border);
|
||||
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&__header-divider {
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--l1-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 15px;
|
||||
}
|
||||
|
||||
&__resource-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
gap: 12px;
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&__unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&__unsaved-dot {
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__unsaved-text {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&__footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.psp-resource {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&--expanded {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 3%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 0 8px 44px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
|
||||
&__radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
|
||||
label {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__select-wrapper {
|
||||
padding: 6px 16px 4px 24px;
|
||||
}
|
||||
|
||||
&__select {
|
||||
width: 100%;
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
.ant-select-selector {
|
||||
background: var(--l2-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 2px !important;
|
||||
padding: 4px 6px !important;
|
||||
min-height: 32px !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border-color: var(--input) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
background: var(--input) !important;
|
||||
border: none !important;
|
||||
border-radius: 2px !important;
|
||||
padding: 0 6px !important;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
color: var(--l1-foreground) !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-item-remove {
|
||||
color: var(--foreground) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__select-popup {
|
||||
.ant-select-item {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
background: var(--l2-background);
|
||||
|
||||
&-option-selected {
|
||||
background: var(--border) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
&-option-active {
|
||||
background: var(--l2-background-hover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-dropdown {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
RadioGroupLabel,
|
||||
} from '@signozhq/ui/radio-group';
|
||||
import { Select, Skeleton } from 'antd';
|
||||
|
||||
import {
|
||||
buildConfig,
|
||||
configsEqual,
|
||||
DEFAULT_RESOURCE_CONFIG,
|
||||
isResourceConfigEqual,
|
||||
} from '../utils';
|
||||
import type {
|
||||
PermissionConfig,
|
||||
PermissionSidePanelProps,
|
||||
ResourceConfig,
|
||||
ResourceDefinition,
|
||||
ScopeType,
|
||||
} from './PermissionSidePanel.types';
|
||||
import { PermissionScope } from './PermissionSidePanel.types';
|
||||
|
||||
import './PermissionSidePanel.styles.scss';
|
||||
|
||||
const RELATIONS_ALL_ONLY = new Set(['list', 'create']);
|
||||
|
||||
interface ResourceRowProps {
|
||||
resource: ResourceDefinition;
|
||||
config: ResourceConfig;
|
||||
isExpanded: boolean;
|
||||
relation: string;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onScopeChange: (id: string, scope: ScopeType) => void;
|
||||
onSelectedIdsChange: (id: string, ids: string[]) => void;
|
||||
}
|
||||
|
||||
function ResourceRow({
|
||||
resource,
|
||||
config,
|
||||
isExpanded,
|
||||
relation,
|
||||
onToggleExpand,
|
||||
onScopeChange,
|
||||
onSelectedIdsChange,
|
||||
}: ResourceRowProps): JSX.Element {
|
||||
const showOnlySelected = !RELATIONS_ALL_ONLY.has(relation);
|
||||
return (
|
||||
<div className="psp-resource">
|
||||
<div
|
||||
className={`psp-resource__row${
|
||||
isExpanded ? ' psp-resource__row--expanded' : ''
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(): void => onToggleExpand(resource.id)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onToggleExpand(resource.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="psp-resource__left">
|
||||
<span className="psp-resource__chevron">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span className="psp-resource__label">{resource.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="psp-resource__body">
|
||||
<RadioGroup
|
||||
value={config.scope}
|
||||
onChange={(val): void => onScopeChange(resource.id, val as ScopeType)}
|
||||
color="robin"
|
||||
className="psp-resource__radio-group"
|
||||
>
|
||||
<div className="psp-resource__radio-item">
|
||||
<RadioGroupItem value={PermissionScope.ALL} id={`${resource.id}-all`} />
|
||||
<RadioGroupLabel htmlFor={`${resource.id}-all`}>All</RadioGroupLabel>
|
||||
</div>
|
||||
|
||||
{showOnlySelected && (
|
||||
<div className="psp-resource__radio-item">
|
||||
<RadioGroupItem
|
||||
value={PermissionScope.ONLY_SELECTED}
|
||||
id={`${resource.id}-only-selected`}
|
||||
/>
|
||||
<RadioGroupLabel htmlFor={`${resource.id}-only-selected`}>
|
||||
Only selected
|
||||
</RadioGroupLabel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="psp-resource__radio-item">
|
||||
<RadioGroupItem
|
||||
value={PermissionScope.NONE}
|
||||
id={`${resource.id}-none`}
|
||||
/>
|
||||
<RadioGroupLabel htmlFor={`${resource.id}-none`}>None</RadioGroupLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{config.scope === PermissionScope.ONLY_SELECTED && showOnlySelected && (
|
||||
<div className="psp-resource__select-wrapper">
|
||||
<Select
|
||||
mode="tags"
|
||||
open={false}
|
||||
allowClear
|
||||
suffixIcon={null}
|
||||
value={config.selectedIds}
|
||||
onChange={(vals: string[]): void =>
|
||||
onSelectedIdsChange(resource.id, vals)
|
||||
}
|
||||
placeholder="Type and press Enter to add..."
|
||||
className="psp-resource__select"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionSidePanel({
|
||||
open,
|
||||
onClose,
|
||||
permissionLabel,
|
||||
relation,
|
||||
resources,
|
||||
initialConfig,
|
||||
isLoading = false,
|
||||
isSaving = false,
|
||||
canEdit = true,
|
||||
onSave,
|
||||
}: PermissionSidePanelProps): JSX.Element | null {
|
||||
const [config, setConfig] = useState<PermissionConfig>(() =>
|
||||
buildConfig(resources, initialConfig),
|
||||
);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setConfig(buildConfig(resources, initialConfig));
|
||||
setExpandedIds(new Set());
|
||||
}
|
||||
}, [open, resources, initialConfig]);
|
||||
|
||||
const savedConfig = useMemo(
|
||||
() => buildConfig(resources, initialConfig),
|
||||
[resources, initialConfig],
|
||||
);
|
||||
|
||||
const unsavedCount = useMemo(() => {
|
||||
if (configsEqual(config, savedConfig)) {
|
||||
return 0;
|
||||
}
|
||||
return Object.keys(config).filter(
|
||||
(id) => !isResourceConfigEqual(config[id], savedConfig[id]),
|
||||
).length;
|
||||
}, [config, savedConfig]);
|
||||
|
||||
const updateResource = useCallback(
|
||||
(id: string, patch: Partial<ResourceConfig>): void => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
[id]: { ...prev[id], ...patch },
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback((id: string): void => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleScopeChange = useCallback(
|
||||
(id: string, scope: ScopeType): void => {
|
||||
updateResource(id, { scope, selectedIds: [] });
|
||||
},
|
||||
[updateResource],
|
||||
);
|
||||
|
||||
const handleSelectedIdsChange = useCallback(
|
||||
(id: string, ids: string[]): void => {
|
||||
updateResource(id, { selectedIds: ids });
|
||||
},
|
||||
[updateResource],
|
||||
);
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
onSave(config);
|
||||
}, [config, onSave]);
|
||||
|
||||
const handleDiscard = useCallback((): void => {
|
||||
setConfig(buildConfig(resources, initialConfig));
|
||||
setExpandedIds(new Set());
|
||||
}, [resources, initialConfig]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="permission-side-panel-backdrop"
|
||||
role="presentation"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className="permission-side-panel">
|
||||
<div className="permission-side-panel__header">
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
<span className="permission-side-panel__header-divider" />
|
||||
<span className="permission-side-panel__title">
|
||||
Edit {permissionLabel} Permissions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="permission-side-panel__content">
|
||||
{isLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
) : (
|
||||
<div className="permission-side-panel__resource-list">
|
||||
{resources.map((resource) => (
|
||||
<ResourceRow
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
config={config[resource.id] ?? DEFAULT_RESOURCE_CONFIG}
|
||||
isExpanded={expandedIds.has(resource.id)}
|
||||
relation={relation}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onScopeChange={handleScopeChange}
|
||||
onSelectedIdsChange={handleSelectedIdsChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="permission-side-panel__footer">
|
||||
{unsavedCount > 0 && (
|
||||
<div className="permission-side-panel__unsaved">
|
||||
<span className="permission-side-panel__unsaved-dot" />
|
||||
<span className="permission-side-panel__unsaved-text">
|
||||
{unsavedCount} unsaved change{unsavedCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="permission-side-panel__footer-actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<X size={14} />}
|
||||
onClick={unsavedCount > 0 ? handleDiscard : onClose}
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{unsavedCount > 0 ? 'Discard' : 'Cancel'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
disabled={isLoading || unsavedCount === 0 || !canEdit}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionSidePanel;
|
||||
@@ -1,40 +0,0 @@
|
||||
export interface ResourceOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ResourceDefinition {
|
||||
id: string;
|
||||
kind: string;
|
||||
type: string;
|
||||
label: string;
|
||||
options?: ResourceOption[];
|
||||
}
|
||||
|
||||
export enum PermissionScope {
|
||||
ALL = 'all',
|
||||
ONLY_SELECTED = 'only_selected',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
export type ScopeType = PermissionScope;
|
||||
|
||||
export interface ResourceConfig {
|
||||
scope: ScopeType;
|
||||
selectedIds: string[];
|
||||
}
|
||||
|
||||
export type PermissionConfig = Record<string, ResourceConfig>;
|
||||
|
||||
export interface PermissionSidePanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
permissionLabel: string;
|
||||
relation: string;
|
||||
resources: ResourceDefinition[];
|
||||
initialConfig?: PermissionConfig;
|
||||
isLoading?: boolean;
|
||||
isSaving?: boolean;
|
||||
canEdit?: boolean;
|
||||
onSave: (config: PermissionConfig) => void;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export { default } from './PermissionSidePanel';
|
||||
export type {
|
||||
PermissionConfig,
|
||||
PermissionSidePanelProps,
|
||||
ResourceConfig,
|
||||
ResourceDefinition,
|
||||
ResourceOption,
|
||||
ScopeType,
|
||||
} from './PermissionSidePanel.types';
|
||||
export { PermissionScope } from './PermissionSidePanel.types';
|
||||
@@ -1,325 +0,0 @@
|
||||
.role-details-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
max-width: 60vw;
|
||||
margin: 0 auto;
|
||||
|
||||
.role-details-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.role-details-title {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
}
|
||||
|
||||
.role-details-permission-item--readonly {
|
||||
cursor: default !important;
|
||||
pointer-events: none;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.role-details-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-details-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.role-details-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.role-details-section-label {
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.role-details-description-text {
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.role-details-info-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.role-details-info-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.role-details-info-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-details-info-name {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.role-details-permissions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.role-details-permissions-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.role-details-permissions-divider {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-top: 2px dotted var(--l1-border);
|
||||
border-bottom: 2px dotted var(--l1-border);
|
||||
height: 7px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.role-details-permissions-learn-more {
|
||||
color: var(--primary);
|
||||
font-size: var(--font-size-xs);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-permission-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.role-details-permission-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 8%, transparent);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 12%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-permission-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-details-permission-item-label {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.role-details-members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.role-details-members-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 2px;
|
||||
|
||||
.role-details-members-search-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.role-details-members-search-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--foreground);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-members-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 420px;
|
||||
border: 1px dashed var(--secondary);
|
||||
border-radius: 3px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.role-details-members-empty-state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 48px 0;
|
||||
flex-grow: 1;
|
||||
|
||||
.role-details-members-empty-emoji {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.role-details-members-empty-text {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
&--bold {
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&--muted {
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-skeleton {
|
||||
padding: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-delete-modal {
|
||||
width: calc(100% - 30px) !important;
|
||||
max-width: 384px;
|
||||
|
||||
.ant-modal-content {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--secondary);
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 16px;
|
||||
background: var(--l2-background);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0 16px 28px 16px;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
|
||||
.cancel-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.065px;
|
||||
}
|
||||
|
||||
.delete-text {
|
||||
color: var(--foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Redirect, useHistory, useLocation } from 'react-router-dom';
|
||||
import { Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Skeleton } from 'antd';
|
||||
import {
|
||||
getGetObjectsQueryKey,
|
||||
useDeleteRole,
|
||||
useGetObjects,
|
||||
useGetRole,
|
||||
usePatchObjects,
|
||||
} from 'api/generated/services/role';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import permissionsType from 'hooks/useAuthZ/permissions.type';
|
||||
import {
|
||||
buildRoleDeletePermission,
|
||||
buildRoleReadPermission,
|
||||
buildRoleUpdatePermission,
|
||||
} from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
|
||||
import type { AuthzResources } from '../utils';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { RoleType } from 'types/roles';
|
||||
import { handleApiError, toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import type { PermissionConfig } from '../PermissionSidePanel';
|
||||
import PermissionSidePanel from '../PermissionSidePanel';
|
||||
import CreateRoleModal from '../RolesComponents/CreateRoleModal';
|
||||
import DeleteRoleModal from '../RolesComponents/DeleteRoleModal';
|
||||
import {
|
||||
buildPatchPayload,
|
||||
derivePermissionTypes,
|
||||
deriveResourcesForRelation,
|
||||
objectsToPermissionConfig,
|
||||
} from '../utils';
|
||||
import OverviewTab from './components/OverviewTab';
|
||||
import { ROLE_ID_REGEX } from './constants';
|
||||
|
||||
import './RoleDetailsPage.styles.scss';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function RoleDetailsPage(): JSX.Element {
|
||||
const { pathname, search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { isRolesEnabled, isLoading: isRolesGateLoading } =
|
||||
useRolesFeatureGate();
|
||||
|
||||
const authzResources: AuthzResources = permissionsType.data;
|
||||
|
||||
// Extract roleId from URL pathname since useParams doesn't work in nested routing
|
||||
const roleIdMatch = pathname.match(ROLE_ID_REGEX);
|
||||
const roleId = roleIdMatch ? roleIdMatch[1] : '';
|
||||
|
||||
// Role name passed as query param by the listing page — used to check read permission
|
||||
// before the role details API resolves. Absent when navigating directly (e.g. deep link),
|
||||
// in which case we skip the FGA check and fall back to the BE guard.
|
||||
const nameFromQuery = useMemo(
|
||||
() => new URLSearchParams(search).get('name') ?? '',
|
||||
[search],
|
||||
);
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [activePermission, setActivePermission] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetRole(
|
||||
{ id: roleId },
|
||||
{ query: { enabled: !!roleId } },
|
||||
);
|
||||
const role = data?.data;
|
||||
const isTransitioning = isFetching && role?.id !== roleId;
|
||||
const isManaged = role?.type === RoleType.MANAGED;
|
||||
|
||||
const roleName = role?.name ?? '';
|
||||
|
||||
// Read check — fires immediately using the name query param so we can gate the page
|
||||
// before the role details API resolves. Skipped when name is absent.
|
||||
const { permissions: readPerms, isLoading: isReadAuthZLoading } = useAuthZ(
|
||||
nameFromQuery ? [buildRoleReadPermission(nameFromQuery)] : [],
|
||||
{ enabled: !!nameFromQuery },
|
||||
);
|
||||
const hasReadPermission = nameFromQuery
|
||||
? (readPerms?.[buildRoleReadPermission(nameFromQuery)]?.isGranted ?? true)
|
||||
: true;
|
||||
|
||||
// Update check uses role name once loaded
|
||||
const { permissions: updatePerms, isLoading: isAuthZLoading } = useAuthZ(
|
||||
roleName && !isManaged ? [buildRoleUpdatePermission(roleName)] : [],
|
||||
{ enabled: !!roleName && !isManaged },
|
||||
);
|
||||
const hasUpdatePermission = isAuthZLoading
|
||||
? false
|
||||
: (updatePerms?.[buildRoleUpdatePermission(roleName)]?.isGranted ?? false);
|
||||
|
||||
const permissionTypes = useMemo(
|
||||
() => derivePermissionTypes(authzResources?.relations ?? null),
|
||||
[authzResources],
|
||||
);
|
||||
|
||||
const resourcesForActivePermission = useMemo(
|
||||
() =>
|
||||
activePermission
|
||||
? deriveResourcesForRelation(authzResources ?? null, activePermission)
|
||||
: [],
|
||||
[authzResources, activePermission],
|
||||
);
|
||||
|
||||
const { data: objectsData, isLoading: isLoadingObjects } = useGetObjects(
|
||||
{ id: roleId, relation: activePermission ?? '' },
|
||||
{
|
||||
query: {
|
||||
enabled: !!activePermission && !!roleId && !isManaged,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const initialConfig = useMemo(() => {
|
||||
if (!objectsData?.data || !activePermission) {
|
||||
return;
|
||||
}
|
||||
return objectsToPermissionConfig(
|
||||
objectsData.data,
|
||||
resourcesForActivePermission,
|
||||
);
|
||||
}, [objectsData, activePermission, resourcesForActivePermission]);
|
||||
|
||||
const handleSaveSuccess = (): void => {
|
||||
toast.success('Permissions saved successfully');
|
||||
if (activePermission) {
|
||||
queryClient.removeQueries(
|
||||
getGetObjectsQueryKey({ id: roleId, relation: activePermission }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const { mutate: patchObjects, isLoading: isSaving } = usePatchObjects({
|
||||
mutation: {
|
||||
onSuccess: handleSaveSuccess,
|
||||
onError: (err) => handleApiError(err, showErrorModal),
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: deleteRole, isLoading: isDeleting } = useDeleteRole({
|
||||
mutation: {
|
||||
onSuccess: (): void => {
|
||||
toast.success('Role deleted successfully');
|
||||
history.push(ROUTES.ROLES_SETTINGS);
|
||||
},
|
||||
onError: (err) => handleApiError(err, showErrorModal),
|
||||
},
|
||||
});
|
||||
|
||||
if (isRolesGateLoading) {
|
||||
return (
|
||||
<div className="role-details-page">
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{ rows: 8 }}
|
||||
className="role-details-skeleton"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRolesEnabled) {
|
||||
return <Redirect to={ROUTES.ROLES_SETTINGS} />;
|
||||
}
|
||||
|
||||
if (!hasReadPermission && readPerms !== null) {
|
||||
return <PermissionDeniedFullPage permissionName="role:read" />;
|
||||
}
|
||||
|
||||
if (isLoading || isTransitioning || (!!nameFromQuery && isReadAuthZLoading)) {
|
||||
return (
|
||||
<div className="role-details-page">
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{ rows: 8 }}
|
||||
className="role-details-skeleton"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="role-details-page">
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
error,
|
||||
'An unexpected error occurred while fetching role details.',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
return (
|
||||
<div className="role-details-page">
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{ rows: 8 }}
|
||||
className="role-details-skeleton"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSave = (config: PermissionConfig): void => {
|
||||
if (!activePermission || !authzResources) {
|
||||
return;
|
||||
}
|
||||
patchObjects({
|
||||
pathParams: { id: roleId, relation: activePermission },
|
||||
data: buildPatchPayload({
|
||||
newConfig: config,
|
||||
initialConfig: initialConfig ?? {},
|
||||
resources: resourcesForActivePermission,
|
||||
authzRes: authzResources,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="role-details-page">
|
||||
<div className="role-details-header">
|
||||
<h2 className="role-details-title">Role — {role.name}</h2>
|
||||
{!isManaged && (
|
||||
<div className="role-details-actions">
|
||||
<AuthZTooltip checks={[buildRoleDeletePermission(role.name)]}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={(): void => setIsDeleteModalOpen(true)}
|
||||
aria-label="Delete role"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<AuthZTooltip checks={[buildRoleUpdatePermission(role.name)]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
>
|
||||
Edit Role Details
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<OverviewTab
|
||||
role={role || null}
|
||||
isManaged={isManaged}
|
||||
permissionTypes={permissionTypes}
|
||||
onPermissionClick={(key): void => setActivePermission(key)}
|
||||
/>
|
||||
{!isManaged && (
|
||||
<>
|
||||
<PermissionSidePanel
|
||||
open={activePermission !== null}
|
||||
onClose={(): void => setActivePermission(null)}
|
||||
permissionLabel={activePermission ? capitalize(activePermission) : ''}
|
||||
relation={activePermission ?? ''}
|
||||
resources={resourcesForActivePermission}
|
||||
initialConfig={initialConfig}
|
||||
isLoading={isLoadingObjects}
|
||||
isSaving={isSaving}
|
||||
canEdit={hasUpdatePermission}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
<CreateRoleModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={(): void => setIsEditModalOpen(false)}
|
||||
initialData={{
|
||||
id: roleId,
|
||||
name: role.name || '',
|
||||
description: role.description || '',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DeleteRoleModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
roleName={role.name || ''}
|
||||
isDeleting={isDeleting}
|
||||
onCancel={(): void => setIsDeleteModalOpen(false)}
|
||||
onConfirm={(): void => deleteRole({ pathParams: { id: roleId } })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleDetailsPage;
|
||||
@@ -1,536 +0,0 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import {
|
||||
customRoleResponse,
|
||||
managedRoleResponse,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import {
|
||||
defaultFeatureFlags,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
invalidLicense,
|
||||
mockUseAuthZDenyAll,
|
||||
mockUseAuthZGrantAll,
|
||||
} from 'tests/authz-test-utils';
|
||||
import RoleDetailsPage from '../RoleDetailsPage';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
|
||||
const MANAGED_ROLE_ID = '019c24aa-2248-756f-9833-984f1ab63819';
|
||||
|
||||
const rolesApiBase = 'http://localhost/api/v1/roles';
|
||||
|
||||
const emptyObjectsResponse = { status: 'success', data: [] };
|
||||
|
||||
const allScopeObjectsResponse = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function setupDefaultHandlers(roleId = CUSTOM_ROLE_ID): void {
|
||||
const roleResponse =
|
||||
roleId === MANAGED_ROLE_ID ? managedRoleResponse : customRoleResponse;
|
||||
|
||||
server.use(
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(roleResponse)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('RoleDetailsPage', () => {
|
||||
it('renders custom role header, tabs, description, permissions, and action buttons', async () => {
|
||||
setupDefaultHandlers();
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('Role — billing-manager'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText('Custom role for managing billing and invoices.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||
expect(screen.getByText('Read')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /edit role details/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /delete role/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows managed-role warning callout and hides edit/delete buttons', async () => {
|
||||
setupDefaultHandlers(MANAGED_ROLE_ID);
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${MANAGED_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText(/Role — signoz-admin/),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'This is a managed role. Permissions and settings are view-only and cannot be modified.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('Edit Role Details')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /delete role/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edit flow: modal opens pre-filled and calls PATCH on save', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
let description = customRoleResponse.data.description;
|
||||
server.use(
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
...customRoleResponse,
|
||||
data: { ...customRoleResponse.data, description },
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.patch(`${rolesApiBase}/:id`, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
patchSpy(body);
|
||||
description = body.description;
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
...customRoleResponse,
|
||||
data: { ...customRoleResponse.data, description },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await screen.findByText('Role — billing-manager');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /edit role details/i }));
|
||||
await expect(
|
||||
screen.findByText('Edit Role Details', { selector: '.ant-modal-title' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(
|
||||
'Enter role name e.g. : Service Owner',
|
||||
);
|
||||
expect(nameInput).toBeDisabled();
|
||||
|
||||
const descField = screen.getByPlaceholderText(
|
||||
'A helpful description of the role',
|
||||
);
|
||||
await user.clear(descField);
|
||||
await user.type(descField, 'Updated description');
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
description: 'Updated description',
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText('Edit Role Details', { selector: '.ant-modal-title' }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText('Updated description'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('delete flow: modal shows role name, DELETE called on confirm', async () => {
|
||||
const deleteSpy = jest.fn();
|
||||
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.delete(`${rolesApiBase}/:id`, (_req, res, ctx) => {
|
||||
deleteSpy();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await screen.findByText('Role — billing-manager');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /delete role/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByText(/Are you sure you want to delete the role/),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: /delete role/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(deleteSpy).toHaveBeenCalled());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(/Are you sure you want to delete the role/),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows PermissionDeniedFullPage when read permission is denied via query param', async () => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}?name=billing-manager`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText(/you don't have permission to view this page/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to the roles list when license is not valid', async () => {
|
||||
render(
|
||||
<Switch>
|
||||
<Route path="/settings/roles/:roleId">
|
||||
<RoleDetailsPage />
|
||||
</Route>
|
||||
<Route path="/settings/roles" exact>
|
||||
<div data-testid="roles-list-redirect-target" />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
appContextOverrides: { activeLicense: invalidLicense },
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('roles-list-redirect-target'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to the roles list when fine-grained authz flag is inactive', async () => {
|
||||
render(
|
||||
<Switch>
|
||||
<Route path="/settings/roles/:roleId">
|
||||
<RoleDetailsPage />
|
||||
</Route>
|
||||
<Route path="/settings/roles" exact>
|
||||
<div data-testid="roles-list-redirect-target" />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
appContextOverrides: {
|
||||
featureFlags: defaultFeatureFlags.map((f) =>
|
||||
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
|
||||
? { ...f, active: false }
|
||||
: f,
|
||||
),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('roles-list-redirect-target'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('permission side panel', () => {
|
||||
beforeEach(() => {
|
||||
// Both hooks mocked so data renders synchronously — no React Query scheduler or MSW round-trip.
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
jest
|
||||
.spyOn(roleApi, 'useGetObjects')
|
||||
.mockReturnValue({ data: emptyObjectsResponse, isLoading: false } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function openCreatePanel(): Promise<HTMLElement> {
|
||||
await screen.findByText('Role — billing-manager');
|
||||
fireEvent.click(screen.getByText('Create'));
|
||||
await screen.findByText('Edit Create Permissions');
|
||||
const panel = document.querySelector(
|
||||
'.permission-side-panel',
|
||||
) as HTMLElement;
|
||||
await within(panel).findByRole('button', { name: 'role' });
|
||||
return panel;
|
||||
}
|
||||
|
||||
async function openReadPanel(): Promise<HTMLElement> {
|
||||
await screen.findByText('Role — billing-manager');
|
||||
fireEvent.click(screen.getByText('Read'));
|
||||
await screen.findByText('Edit Read Permissions');
|
||||
const panel = document.querySelector(
|
||||
'.permission-side-panel',
|
||||
) as HTMLElement;
|
||||
await within(panel).findByRole('button', { name: 'role' });
|
||||
return panel;
|
||||
}
|
||||
|
||||
it('Save Changes is disabled until a resource scope is changed', async () => {
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
).toBeDisabled();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
).not.toBeDisabled();
|
||||
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('set scope to All → patchObjects additions: ["*"], deletions: null', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
server.use(
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: [
|
||||
{
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
deletions: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('set scope to Only selected with IDs → patchObjects additions contain those IDs', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
server.use(
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openReadPanel();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
|
||||
// Default is NONE, so switch to Only selected first to reveal the combobox
|
||||
fireEvent.click(screen.getByText('Only selected'));
|
||||
|
||||
const combobox = within(panel).getByRole('combobox');
|
||||
fireEvent.change(combobox, { target: { value: 'role-001' } });
|
||||
fireEvent.keyDown(combobox, { key: 'Enter', keyCode: 13 });
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: [
|
||||
{
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
selectors: ['role-001'],
|
||||
},
|
||||
],
|
||||
deletions: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('set scope to None on create panel (existing All) → patchObjects deletions: ["*"], additions: null', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
jest.spyOn(roleApi, 'useGetObjects').mockReturnValue({
|
||||
data: allScopeObjectsResponse,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
server.use(
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
|
||||
fireEvent.click(screen.getByText('None'));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: null,
|
||||
deletions: [
|
||||
{
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('existing All scope changed to Only selected (empty) → patchObjects deletions: ["*"], additions: null', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
jest.spyOn(roleApi, 'useGetObjects').mockReturnValue({
|
||||
data: allScopeObjectsResponse,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
server.use(
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openReadPanel();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
|
||||
fireEvent.click(screen.getByText('Only selected'));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: null,
|
||||
deletions: [
|
||||
{
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('unsaved changes counter shown on scope change, Discard resets it', async () => {
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
|
||||
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: /discard/i }));
|
||||
|
||||
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Search } from '@signozhq/icons';
|
||||
|
||||
function MembersTab(): JSX.Element {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
return (
|
||||
<div className="role-details-members">
|
||||
<div className="role-details-members-search">
|
||||
<Search size={12} className="role-details-members-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="role-details-members-search-input"
|
||||
placeholder="Search and add members..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Todo: Right now we are only adding the empty state in this cut */}
|
||||
<div className="role-details-members-content">
|
||||
<div className="role-details-members-empty-state">
|
||||
<span
|
||||
className="role-details-members-empty-emoji"
|
||||
role="img"
|
||||
aria-label="monocle face"
|
||||
>
|
||||
🧐
|
||||
</span>
|
||||
<p className="role-details-members-empty-text">
|
||||
<span className="role-details-members-empty-text--bold">
|
||||
No members added.
|
||||
</span>{' '}
|
||||
<span className="role-details-members-empty-text--muted">
|
||||
Start adding members to this role.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MembersTab;
|
||||
@@ -1,87 +0,0 @@
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
|
||||
import { PermissionType, TimestampBadge } from '../../utils';
|
||||
import PermissionItem from './PermissionItem';
|
||||
import { AuthtypesRelationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface OverviewTabProps {
|
||||
role: {
|
||||
description?: string;
|
||||
createdAt?: Date | string;
|
||||
updatedAt?: Date | string;
|
||||
} | null;
|
||||
isManaged: boolean;
|
||||
permissionTypes: PermissionType[];
|
||||
onPermissionClick: (relationKey: string) => void;
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
role,
|
||||
isManaged,
|
||||
permissionTypes,
|
||||
onPermissionClick,
|
||||
}: OverviewTabProps): JSX.Element {
|
||||
return (
|
||||
<div className="role-details-overview">
|
||||
{isManaged && (
|
||||
<Callout
|
||||
type="warning"
|
||||
showIcon
|
||||
title="This is a managed role. Permissions and settings are view-only and cannot be modified."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="role-details-meta">
|
||||
<div>
|
||||
<p className="role-details-section-label">Description</p>
|
||||
<p className="role-details-description-text">{role?.description || '—'}</p>
|
||||
</div>
|
||||
|
||||
<div className="role-details-info-row">
|
||||
<div className="role-details-info-col">
|
||||
<p className="role-details-section-label">Created At</p>
|
||||
<div className="role-details-info-value">
|
||||
<TimestampBadge date={role?.createdAt} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="role-details-info-col">
|
||||
<p className="role-details-section-label">Last Modified At</p>
|
||||
<div className="role-details-info-value">
|
||||
<TimestampBadge date={role?.updatedAt} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="role-details-permissions">
|
||||
<div className="role-details-permissions-header">
|
||||
<span className="role-details-section-label">Permissions</span>
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/iam/permissions/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="role-details-permissions-learn-more"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
<hr className="role-details-permissions-divider" />
|
||||
</div>
|
||||
|
||||
<div className="role-details-permission-list">
|
||||
{permissionTypes
|
||||
.filter((p) => p.key !== AuthtypesRelationDTO.assignee)
|
||||
.map((permissionType) => (
|
||||
<PermissionItem
|
||||
key={permissionType.key}
|
||||
permissionType={permissionType}
|
||||
isManaged={isManaged}
|
||||
onPermissionClick={onPermissionClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverviewTab;
|
||||
@@ -1,54 +0,0 @@
|
||||
import { ChevronRight } from '@signozhq/icons';
|
||||
|
||||
import { PermissionType } from '../../utils';
|
||||
|
||||
interface PermissionItemProps {
|
||||
permissionType: PermissionType;
|
||||
isManaged: boolean;
|
||||
onPermissionClick: (key: string) => void;
|
||||
}
|
||||
|
||||
function PermissionItem({
|
||||
permissionType,
|
||||
isManaged,
|
||||
onPermissionClick,
|
||||
}: PermissionItemProps): JSX.Element {
|
||||
const { key, label, icon } = permissionType;
|
||||
|
||||
if (isManaged) {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="role-details-permission-item role-details-permission-item--readonly"
|
||||
>
|
||||
<div className="role-details-permission-item-left">
|
||||
{icon}
|
||||
<span className="role-details-permission-item-label">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="role-details-permission-item"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(): void => onPermissionClick(key)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onPermissionClick(key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="role-details-permission-item-left">
|
||||
{icon}
|
||||
<span className="role-details-permission-item-label">{label}</span>
|
||||
</div>
|
||||
<ChevronRight size={14} color="var(--foreground)" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionItem;
|
||||
@@ -1,22 +0,0 @@
|
||||
import {
|
||||
BadgePlus,
|
||||
Eye,
|
||||
LayoutList,
|
||||
PencilRuler,
|
||||
Settings,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
export const ROLE_ID_REGEX = /\/settings\/roles\/([^/]+)/;
|
||||
|
||||
export type IconComponent = React.ComponentType<any>;
|
||||
|
||||
export const PERMISSION_ICON_MAP: Record<string, IconComponent> = {
|
||||
create: BadgePlus,
|
||||
list: LayoutList,
|
||||
read: Eye,
|
||||
update: PencilRuler,
|
||||
delete: Trash2,
|
||||
};
|
||||
|
||||
export const FALLBACK_PERMISSION_ICON: IconComponent = Settings;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './RoleDetailsPage';
|
||||
@@ -1,189 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { generatePath, useHistory } from 'react-router-dom';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Form, Modal } from 'antd';
|
||||
import {
|
||||
invalidateGetRole,
|
||||
invalidateListRoles,
|
||||
useCreateRole,
|
||||
usePatchRole,
|
||||
} from 'api/generated/services/role';
|
||||
import {
|
||||
AuthtypesPostableRoleDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { handleApiError } from 'utils/errorUtils';
|
||||
|
||||
import '../RolesSettings.styles.scss';
|
||||
|
||||
export interface CreateRoleModalInitialData {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CreateRoleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialData?: CreateRoleModalInitialData;
|
||||
}
|
||||
|
||||
interface CreateRoleFormValues {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function CreateRoleModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialData,
|
||||
}: CreateRoleModalProps): JSX.Element {
|
||||
const [form] = Form.useForm<CreateRoleFormValues>();
|
||||
const queryClient = useQueryClient();
|
||||
const history = useHistory();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const isEditMode = !!initialData?.id;
|
||||
const prevIsOpen = useRef(isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !prevIsOpen.current) {
|
||||
if (isEditMode && initialData) {
|
||||
form.setFieldsValue({
|
||||
name: initialData.name,
|
||||
description: initialData.description || '',
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}
|
||||
prevIsOpen.current = isOpen;
|
||||
}, [isOpen, isEditMode, initialData, form]);
|
||||
|
||||
const handleSuccess = async (
|
||||
message: string,
|
||||
redirectPath?: string,
|
||||
): Promise<void> => {
|
||||
await invalidateListRoles(queryClient);
|
||||
if (isEditMode && initialData?.id) {
|
||||
await invalidateGetRole(queryClient, { id: initialData.id });
|
||||
}
|
||||
toast.success(message);
|
||||
form.resetFields();
|
||||
onClose();
|
||||
if (redirectPath) {
|
||||
history.push(redirectPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (error: ErrorType<RenderErrorResponseDTO>): void => {
|
||||
handleApiError(error, showErrorModal);
|
||||
};
|
||||
|
||||
const { mutate: createRole, isLoading: isCreating } = useCreateRole({
|
||||
mutation: {
|
||||
onSuccess: (res) =>
|
||||
handleSuccess(
|
||||
'Role created successfully',
|
||||
generatePath(ROUTES.ROLE_DETAILS, { roleId: res.data.id }),
|
||||
),
|
||||
onError: handleError,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: patchRole, isLoading: isPatching } = usePatchRole({
|
||||
mutation: {
|
||||
onSuccess: () => handleSuccess('Role updated successfully'),
|
||||
onError: handleError,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (isEditMode && initialData?.id) {
|
||||
patchRole({
|
||||
pathParams: { id: initialData.id },
|
||||
data: { description: values.description || '' },
|
||||
});
|
||||
} else {
|
||||
const data: AuthtypesPostableRoleDTO = {
|
||||
name: values.name,
|
||||
description: values.description || '',
|
||||
transactionGroups: [],
|
||||
};
|
||||
createRole({ data });
|
||||
}
|
||||
} catch {
|
||||
// form validation failed; antd handles inline error display
|
||||
}
|
||||
}, [form, createRole, patchRole, isEditMode, initialData]);
|
||||
|
||||
const onCancel = useCallback((): void => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
}, [form, onClose]);
|
||||
|
||||
const isLoading = isCreating || isPatching;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onCancel={onCancel}
|
||||
title={isEditMode ? 'Edit Role Details' : 'Create a New Role'}
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
size="sm"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSubmit}
|
||||
loading={isLoading}
|
||||
size="sm"
|
||||
>
|
||||
{isEditMode ? 'Save Changes' : 'Create Role'}
|
||||
</Button>,
|
||||
]}
|
||||
destroyOnClose
|
||||
className="create-role-modal"
|
||||
width={530}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="create-role-form">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Name"
|
||||
rules={[{ required: true, message: 'Role name is required' }]}
|
||||
>
|
||||
<Input
|
||||
disabled={isEditMode}
|
||||
placeholder="Enter role name e.g. : Service Owner"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<textarea
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="A helpful description of the role"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateRoleModal;
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Modal } from 'antd';
|
||||
|
||||
interface DeleteRoleModalProps {
|
||||
isOpen: boolean;
|
||||
roleName: string;
|
||||
isDeleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
function DeleteRoleModal({
|
||||
isOpen,
|
||||
roleName,
|
||||
isDeleting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteRoleModalProps): JSX.Element {
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onCancel={onCancel}
|
||||
title={<span className="title">Delete Role</span>}
|
||||
closable
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
className="cancel-btn"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onCancel}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="delete"
|
||||
className="delete-btn"
|
||||
prefix={<Trash2 size={14} />}
|
||||
onClick={onConfirm}
|
||||
loading={isDeleting}
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
>
|
||||
Delete Role
|
||||
</Button>,
|
||||
]}
|
||||
destroyOnClose
|
||||
className="role-details-delete-modal"
|
||||
>
|
||||
<p className="delete-text">
|
||||
Are you sure you want to delete the role <strong>{roleName}</strong>? This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteRoleModal;
|
||||
@@ -0,0 +1,167 @@
|
||||
.rolesListingTable {
|
||||
margin-top: 12px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tableInner {
|
||||
min-width: 850px;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.headerCell {
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.44px;
|
||||
color: var(--foreground);
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.headerCellName {
|
||||
flex: 0 0 180px;
|
||||
}
|
||||
|
||||
.headerCellDescription {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerCellCreatedAt {
|
||||
flex: 0 0 180px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.headerCellUpdatedAt {
|
||||
flex: 0 0 180px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
border-top: 1px solid var(--secondary);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
line-height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sectionHeaderCount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 9px;
|
||||
background: var(--l3-background);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 2%, transparent);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.tableRowClickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 5%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.tableCell {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.tableCellName {
|
||||
flex: 0 0 180px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tableCellDescription {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableCellCreatedAt {
|
||||
flex: 0 0 180px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tableCellUpdatedAt {
|
||||
flex: 0 0 180px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 16px;
|
||||
|
||||
:global(.ant-pagination-total-text) {
|
||||
margin-right: auto;
|
||||
|
||||
.numbers {
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.total {
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.descriptionTooltip {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import cx from 'classnames';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RoleListPermission } from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
@@ -16,7 +16,7 @@ import { useTimezone } from 'providers/Timezone';
|
||||
import { RoleType } from 'types/roles';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import '../RolesSettings.styles.scss';
|
||||
import styles from './RolesListingTable.module.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -41,7 +41,7 @@ function RolesListingTable({
|
||||
const { data, isLoading, isError, error } = useListRoles({
|
||||
query: { enabled: hasListPermission },
|
||||
});
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
|
||||
const history = useHistory();
|
||||
const urlQuery = useUrlQuery();
|
||||
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
|
||||
@@ -57,19 +57,6 @@ function RolesListingTable({
|
||||
|
||||
const roles = useMemo(() => data?.data ?? [], [data]);
|
||||
|
||||
const formatTimestamp = (date?: Date | string): string => {
|
||||
if (!date) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(date);
|
||||
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
};
|
||||
|
||||
const filteredRoles = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return roles;
|
||||
@@ -95,7 +82,6 @@ function RolesListingTable({
|
||||
[filteredRoles],
|
||||
);
|
||||
|
||||
// Combine managed + custom into a flat display list for pagination
|
||||
const displayList = useMemo((): DisplayItem[] => {
|
||||
const result: DisplayItem[] = [];
|
||||
|
||||
@@ -116,7 +102,6 @@ function RolesListingTable({
|
||||
|
||||
const totalRoleCount = managedRoles.length + customRoles.length;
|
||||
|
||||
// Ensure current page is valid; if out of bounds, redirect to last available page
|
||||
useEffect(() => {
|
||||
if (isLoading || totalRoleCount === 0) {
|
||||
return;
|
||||
@@ -127,7 +112,6 @@ function RolesListingTable({
|
||||
}
|
||||
}, [isLoading, totalRoleCount, currentPage, setCurrentPage]);
|
||||
|
||||
// Paginate: count only role items, but include section headers contextually
|
||||
const paginatedItems = useMemo((): DisplayItem[] => {
|
||||
const startRole = (currentPage - 1) * PAGE_SIZE;
|
||||
const endRole = startRole + PAGE_SIZE;
|
||||
@@ -140,7 +124,6 @@ function RolesListingTable({
|
||||
lastSection = item;
|
||||
} else {
|
||||
if (roleIndex >= startRole && roleIndex < endRole) {
|
||||
// Insert section header before first role in that section on this page
|
||||
if (lastSection) {
|
||||
result.push(lastSection);
|
||||
lastSection = null;
|
||||
@@ -153,6 +136,16 @@ function RolesListingTable({
|
||||
return result;
|
||||
}, [displayList, currentPage]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(roleId: string, roleName: string): void => {
|
||||
if (isRolesEnabled) {
|
||||
const url = `${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}?name=${encodeURIComponent(roleName)}`;
|
||||
history.push(url);
|
||||
}
|
||||
},
|
||||
[isRolesEnabled, history],
|
||||
);
|
||||
|
||||
const showPaginationItem = (total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
<span className="numbers">
|
||||
@@ -168,7 +161,7 @@ function RolesListingTable({
|
||||
|
||||
if (isAuthZLoading || isLoading) {
|
||||
return (
|
||||
<div className="roles-listing-table">
|
||||
<div className={styles.rolesListingTable}>
|
||||
<Skeleton active paragraph={{ rows: 5 }} />
|
||||
</div>
|
||||
);
|
||||
@@ -176,7 +169,7 @@ function RolesListingTable({
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="roles-listing-table">
|
||||
<div className={styles.rolesListingTable}>
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
error,
|
||||
@@ -189,31 +182,27 @@ function RolesListingTable({
|
||||
|
||||
if (filteredRoles.length === 0) {
|
||||
return (
|
||||
<div className="roles-listing-table">
|
||||
<div className="roles-table-empty">
|
||||
<div className={styles.rolesListingTable}>
|
||||
<div className={styles.emptyState}>
|
||||
{searchQuery ? 'No roles match your search.' : 'No roles found.'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navigateToRole = (roleId: string, roleName?: string): void => {
|
||||
const search = roleName ? `?name=${encodeURIComponent(roleName)}` : '';
|
||||
history.push(`${ROUTES.ROLE_DETAILS.replace(':roleId', roleId)}${search}`);
|
||||
};
|
||||
|
||||
// todo: use table from periscope when its available for consumption
|
||||
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={`roles-table-row${isRolesEnabled ? ' roles-table-row--clickable' : ''}`}
|
||||
className={cx(styles.tableRow, {
|
||||
[styles.tableRowClickable]: isRolesEnabled,
|
||||
})}
|
||||
role={isRolesEnabled ? 'button' : undefined}
|
||||
tabIndex={isRolesEnabled ? 0 : undefined}
|
||||
onClick={
|
||||
isRolesEnabled
|
||||
? (): void => {
|
||||
if (role.id) {
|
||||
navigateToRole(role.id, role.name);
|
||||
if (role.id && role.name) {
|
||||
handleRowClick(role.id, role.name);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
@@ -221,56 +210,54 @@ function RolesListingTable({
|
||||
onKeyDown={
|
||||
isRolesEnabled
|
||||
? (e): void => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && role.id) {
|
||||
navigateToRole(role.id, role.name);
|
||||
if ((e.key === 'Enter' || e.key === ' ') && role.id && role.name) {
|
||||
handleRowClick(role.id, role.name);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="roles-table-cell roles-table-cell--name">
|
||||
<div className={cx(styles.tableCell, styles.tableCellName)}>
|
||||
{role.name ?? '—'}
|
||||
</div>
|
||||
<div className="roles-table-cell roles-table-cell--description">
|
||||
<div className={cx(styles.tableCell, styles.tableCellDescription)}>
|
||||
<LineClampedText
|
||||
text={role.description ?? '—'}
|
||||
tooltipProps={{ overlayClassName: 'roles-description-tooltip' }}
|
||||
tooltipProps={{ overlayClassName: styles.descriptionTooltip }}
|
||||
/>
|
||||
</div>
|
||||
<div className="roles-table-cell roles-table-cell--updated-at">
|
||||
{formatTimestamp(role.updatedAt)}
|
||||
<div className={cx(styles.tableCell, styles.tableCellUpdatedAt)}>
|
||||
{formatTimezoneAdjustedTimestampOptional(role.updatedAt)}
|
||||
</div>
|
||||
<div className="roles-table-cell roles-table-cell--created-at">
|
||||
{formatTimestamp(role.createdAt)}
|
||||
<div className={cx(styles.tableCell, styles.tableCellCreatedAt)}>
|
||||
{formatTimezoneAdjustedTimestampOptional(role.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="roles-listing-table">
|
||||
<div className="roles-table-scroll-container">
|
||||
<div className="roles-table-inner">
|
||||
<div className="roles-table-header">
|
||||
<div className="roles-table-header-cell roles-table-header-cell--name">
|
||||
Name
|
||||
</div>
|
||||
<div className="roles-table-header-cell roles-table-header-cell--description">
|
||||
<div className={styles.rolesListingTable}>
|
||||
<div className={styles.scrollContainer}>
|
||||
<div className={styles.tableInner}>
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={cx(styles.headerCell, styles.headerCellName)}>Name</div>
|
||||
<div className={cx(styles.headerCell, styles.headerCellDescription)}>
|
||||
Description
|
||||
</div>
|
||||
<div className="roles-table-header-cell roles-table-header-cell--updated-at">
|
||||
<div className={cx(styles.headerCell, styles.headerCellUpdatedAt)}>
|
||||
Updated At
|
||||
</div>
|
||||
<div className="roles-table-header-cell roles-table-header-cell--created-at">
|
||||
<div className={cx(styles.headerCell, styles.headerCellCreatedAt)}>
|
||||
Created At
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{paginatedItems.map((item) =>
|
||||
item.type === 'section' ? (
|
||||
<h3 key={`section-${item.label}`} className="roles-table-section-header">
|
||||
<h3 key={`section-${item.label}`} className={styles.sectionHeader}>
|
||||
{item.label}
|
||||
{item.count !== undefined && (
|
||||
<span className="roles-table-section-header__count">{item.count}</span>
|
||||
<span className={styles.sectionHeaderCount}>{item.count}</span>
|
||||
)}
|
||||
</h3>
|
||||
) : (
|
||||
@@ -288,7 +275,7 @@ function RolesListingTable({
|
||||
showSizeChanger={false}
|
||||
hideOnSinglePage
|
||||
onChange={(page): void => setCurrentPage(page)}
|
||||
className="roles-table-pagination"
|
||||
className={styles.pagination}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
229
frontend/src/container/RolesSettings/RolesSettings.module.scss
Normal file
229
frontend/src/container/RolesSettings/RolesSettings.module.scss
Normal file
@@ -0,0 +1,229 @@
|
||||
.rolesSettingsHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rolesSettingsHeaderTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rolesSettingsHeaderDescription {
|
||||
color: var(--foreground);
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rolesSettingsHeaderLearnMore {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.rolesSettingsContent {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.rolesSettingsToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.roleSettingsToolbarButton {
|
||||
display: flex;
|
||||
width: 156px;
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.rolesDescriptionTooltip {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.rolesListingTable {
|
||||
margin-top: 12px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rolesTableScrollContainer {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rolesTableInner {
|
||||
min-width: 850px;
|
||||
}
|
||||
|
||||
.rolesTableHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.rolesTableHeaderCell {
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.44px;
|
||||
color: var(--foreground);
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.rolesTableHeaderCellName {
|
||||
flex: 0 0 180px;
|
||||
}
|
||||
|
||||
.rolesTableHeaderCellDescription {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rolesTableHeaderCellCreatedAt {
|
||||
flex: 0 0 180px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rolesTableHeaderCellUpdatedAt {
|
||||
flex: 0 0 180px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rolesTableSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
border-top: 1px solid var(--secondary);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
line-height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rolesTableSectionHeaderCount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 9px;
|
||||
background: var(--l3-background);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rolesTableRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 2%, transparent);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.rolesTableRowClickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 5%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.rolesTableCell {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.rolesTableCellName {
|
||||
flex: 0 0 180px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rolesTableCellDescription {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rolesTableCellCreatedAt {
|
||||
flex: 0 0 180px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rolesTableCellUpdatedAt {
|
||||
flex: 0 0 180px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rolesTableEmpty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.rolesTablePagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 16px;
|
||||
|
||||
:global(.ant-pagination-total-text) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
:global(.ant-pagination-total-text .numbers) {
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
:global(.ant-pagination-total-text .total) {
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
.roles-settings {
|
||||
.roles-settings-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
|
||||
.roles-settings-header-title {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.roles-settings-header-description {
|
||||
color: var(--foreground);
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.roles-settings-header-learn-more {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.roles-settings-content {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.roles-settings-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.role-settings-toolbar-button {
|
||||
display: flex;
|
||||
width: 156px;
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.roles-description-tooltip {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.roles-listing-table {
|
||||
margin-top: 12px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.roles-table-scroll-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.roles-table-inner {
|
||||
min-width: 850px;
|
||||
}
|
||||
|
||||
.roles-table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.roles-table-header-cell {
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.44px;
|
||||
color: var(--foreground);
|
||||
line-height: 16px;
|
||||
|
||||
&--name {
|
||||
flex: 0 0 180px;
|
||||
}
|
||||
|
||||
&--description {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&--created-at {
|
||||
flex: 0 0 180px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&--updated-at {
|
||||
flex: 0 0 180px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.roles-table-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
border-top: 1px solid var(--secondary);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
line-height: 16px;
|
||||
margin: 0;
|
||||
|
||||
&__count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 9px;
|
||||
background: var(--l3-background);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.roles-table-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 2%, transparent);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
gap: 24px;
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 5%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.roles-table-cell {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
line-height: 20px;
|
||||
|
||||
&--name {
|
||||
flex: 0 0 180px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&--description {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&--created-at {
|
||||
flex: 0 0 180px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&--updated-at {
|
||||
flex: 0 0 180px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.roles-table-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.roles-table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 16px;
|
||||
|
||||
.ant-pagination-total-text {
|
||||
margin-right: auto;
|
||||
|
||||
.numbers {
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.total {
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create-role-modal {
|
||||
.ant-modal-content {
|
||||
padding: 0;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
padding: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
top: 14px;
|
||||
inset-inline-end: 16px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--foreground);
|
||||
|
||||
.ant-modal-close-x {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.065px;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.create-role-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-form-item-label {
|
||||
padding-bottom: 8px;
|
||||
|
||||
label {
|
||||
color: var(--foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
&::placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
background: var(--input-background, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
padding: 6px 8px;
|
||||
font-family: Inter;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border-color: var(--input);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
height: 56px;
|
||||
border-top: 1px solid var(--secondary);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,42 @@
|
||||
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 AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RoleCreatePermission } from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
|
||||
import CreateRoleModal from './RolesComponents/CreateRoleModal';
|
||||
import RolesListingTable from './RolesComponents/RolesListingTable';
|
||||
|
||||
import './RolesSettings.styles.scss';
|
||||
import styles from './RolesSettings.module.scss';
|
||||
|
||||
function RolesSettings(): JSX.Element {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const history = useHistory();
|
||||
const { isRolesEnabled } = useRolesFeatureGate();
|
||||
|
||||
return (
|
||||
<div className="roles-settings" data-testid="roles-settings">
|
||||
<div className="roles-settings-header">
|
||||
<h3 className="roles-settings-header-title">Roles</h3>
|
||||
<p className="roles-settings-header-description">
|
||||
Create and manage custom roles for your team.{' '}
|
||||
<div data-testid="roles-settings">
|
||||
<div className={styles.rolesSettingsHeader}>
|
||||
<h3 className={styles.rolesSettingsHeaderTitle}>Roles</h3>
|
||||
<p className={styles.rolesSettingsHeaderDescription}>
|
||||
{isRolesEnabled
|
||||
? 'Create and manage custom roles for your team. '
|
||||
: 'The built-in roles of this instance.'}{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/iam/roles/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="roles-settings-header-learn-more"
|
||||
className={styles.rolesSettingsHeaderLearnMore}
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="roles-settings-content">
|
||||
<div className="roles-settings-toolbar">
|
||||
<div className={styles.rolesSettingsContent}>
|
||||
<div className={styles.rolesSettingsToolbar}>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search for roles..."
|
||||
@@ -45,8 +48,8 @@ function RolesSettings(): JSX.Element {
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="role-settings-toolbar-button"
|
||||
onClick={(): void => setIsCreateModalOpen(true)}
|
||||
className={styles.roleSettingsToolbarButton}
|
||||
onClick={(): void => history.push(ROUTES.ROLE_CREATE)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Custom role
|
||||
@@ -56,10 +59,6 @@ function RolesSettings(): JSX.Element {
|
||||
</div>
|
||||
<RolesListingTable searchQuery={searchQuery} />
|
||||
</div>
|
||||
<CreateRoleModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={(): void => setIsCreateModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.readOnlyJsonViewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editorContainer {
|
||||
position: relative;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 24px;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import MEditor from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import {
|
||||
defineJsonTheme,
|
||||
JSON_THEME_DARK,
|
||||
READONLY_EDITOR_OPTIONS,
|
||||
} from '../monaco.config';
|
||||
import { RolePermissionsData } from '../types';
|
||||
import { transformResourcePermissionsToTransactionGroups } from '../hooks/useRolePermissions';
|
||||
|
||||
import styles from './ReadOnlyJsonViewer.module.scss';
|
||||
|
||||
export interface ReadOnlyJsonViewerProps {
|
||||
permissions: RolePermissionsData;
|
||||
}
|
||||
|
||||
function ReadOnlyJsonViewer({
|
||||
permissions,
|
||||
}: ReadOnlyJsonViewerProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [copyState, copyToClipboard] = useCopyToClipboard();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const jsonContent = useMemo(() => {
|
||||
if (!permissions.resources) {
|
||||
return '[]';
|
||||
}
|
||||
const transactionGroups = transformResourcePermissionsToTransactionGroups(
|
||||
permissions.resources,
|
||||
);
|
||||
return JSON.stringify(transactionGroups, null, 2);
|
||||
}, [permissions.resources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState.value) {
|
||||
setCopied(true);
|
||||
const timer = setTimeout(() => setCopied(false), 1500);
|
||||
return (): void => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [copyState]);
|
||||
|
||||
const handleCopy = useCallback((): void => {
|
||||
copyToClipboard(jsonContent);
|
||||
}, [copyToClipboard, jsonContent]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.readOnlyJsonViewer}
|
||||
data-testid="read-only-json-viewer"
|
||||
>
|
||||
<div className={styles.editorContainer}>
|
||||
<TooltipSimple title={copied ? 'Copied!' : 'Copy JSON'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.copyButton}
|
||||
onClick={handleCopy}
|
||||
data-testid="read-only-json-viewer-copy-button"
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={14} color={Color.BG_FOREST_400} />
|
||||
) : (
|
||||
<Copy
|
||||
size={14}
|
||||
color={isDarkMode ? Color.BG_VANILLA_400 : Color.TEXT_INK_400}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<MEditor
|
||||
value={jsonContent}
|
||||
language="json"
|
||||
options={READONLY_EDITOR_OPTIONS}
|
||||
height="100%"
|
||||
theme={isDarkMode ? JSON_THEME_DARK : 'light'}
|
||||
beforeMount={defineJsonTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReadOnlyJsonViewer;
|
||||
@@ -0,0 +1,210 @@
|
||||
.viewRolePage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.viewRolePageHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.viewRolePageHeaderLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.backButton {
|
||||
--button-padding: var(--spacing-3);
|
||||
}
|
||||
|
||||
.viewRolePageActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
margin-right: 0px;
|
||||
|
||||
&:disabled {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.unsavedText {
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.viewRolePageContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.viewRolePageForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.formRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.formField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-disabled-background: var(--l2-background);
|
||||
|
||||
input::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.viewRolePageDivider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.roleTabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
--tab-content-padding: 0px;
|
||||
|
||||
[role='tabpanel'] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.permissionSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.permissionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.permissionTitle {
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.permissionDivider {
|
||||
height: 7px;
|
||||
flex: 1 1 0%;
|
||||
border-width: medium medium;
|
||||
border-style: none none;
|
||||
border-color: currentcolor currentcolor;
|
||||
border-image: initial;
|
||||
border-top: 2px dotted var(--l1-border);
|
||||
border-bottom: 2px dotted var(--l1-border);
|
||||
margin: 0px var(--spacing-4);
|
||||
}
|
||||
|
||||
.permissionModeToggle {
|
||||
display: inline-flex;
|
||||
grid-auto-flow: column;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.permissionModeItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: var(--spacing-3) var(--spacing-6);
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.permissionModeInput {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
* {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[data-state='checked'] + label {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.permissionContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ArrowLeft } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Skeleton } from 'antd';
|
||||
import { useGetRole } from 'api/generated/services/role';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import { useDeleteRoleModal } from 'container/RolesSettings/DeleteRoleModal/useDeleteRoleModal';
|
||||
import { useRoleAuthZ } from 'container/RolesSettings/hooks/useRoleAuthZ';
|
||||
import { transformApiToRolePermissions } from 'container/RolesSettings/hooks/useRolePermissions';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
import { RoleType } from 'types/roles';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import DeleteRoleModal from '../DeleteRoleModal/DeleteRoleModal';
|
||||
import PermissionOverview from './components/PermissionOverview';
|
||||
import ReadOnlyJsonViewer from './ReadOnlyJsonViewer';
|
||||
import { useViewRolePageActions } from './useViewRolePageActions';
|
||||
|
||||
import styles from './ViewRolePage.module.scss';
|
||||
|
||||
function ViewRolePage(): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
|
||||
const { isRolesEnabled, isLoading: isFeatureGateLoading } =
|
||||
useRolesFeatureGate();
|
||||
|
||||
const {
|
||||
roleId,
|
||||
roleName,
|
||||
activeTab,
|
||||
viewMode,
|
||||
expandedResources,
|
||||
setExpandedResources,
|
||||
handleRedirectToUpdate,
|
||||
handleCancel,
|
||||
handleModeChange,
|
||||
handleTabChange,
|
||||
} = useViewRolePageActions();
|
||||
|
||||
const {
|
||||
hasReadPermission,
|
||||
readRolePermission,
|
||||
hasUpdatePermission,
|
||||
updateRolePermission,
|
||||
hasDeletePermission,
|
||||
isAuthZLoading,
|
||||
} = useRoleAuthZ(roleName);
|
||||
|
||||
const { data, isLoading, error } = useGetRole(
|
||||
{ id: roleId ?? '' },
|
||||
{ query: { enabled: !!roleId && hasReadPermission } },
|
||||
);
|
||||
const role = data?.data;
|
||||
const isManaged = role?.type === RoleType.MANAGED;
|
||||
|
||||
const {
|
||||
isDeleteModalOpen,
|
||||
isDeleteDisabled,
|
||||
deleteDisabledReason,
|
||||
deleteError,
|
||||
handleOpenDeleteModal,
|
||||
handleCloseDeleteModal,
|
||||
handleConfirmDelete,
|
||||
} = useDeleteRoleModal({
|
||||
roleId,
|
||||
isManaged: isManaged ?? false,
|
||||
hasDeletePermission,
|
||||
onDeleteSuccess: handleCancel,
|
||||
});
|
||||
|
||||
const tabItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'overview' as const,
|
||||
label: 'Overview',
|
||||
children: (
|
||||
<div className={styles.permissionSection}>
|
||||
<div className={styles.permissionHeader}>
|
||||
<Typography
|
||||
as="span"
|
||||
size="small"
|
||||
weight="medium"
|
||||
color="muted"
|
||||
className={styles.permissionTitle}
|
||||
>
|
||||
Transaction Groups
|
||||
</Typography>
|
||||
<hr className={styles.permissionDivider} />
|
||||
<RadioGroup
|
||||
className={styles.permissionModeToggle}
|
||||
value={viewMode}
|
||||
onChange={handleModeChange}
|
||||
testId="permission-view-mode"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="list"
|
||||
containerClassName={styles.permissionModeItem}
|
||||
className={styles.permissionModeInput}
|
||||
testId="permission-view-mode-list"
|
||||
>
|
||||
List
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
value="json"
|
||||
containerClassName={styles.permissionModeItem}
|
||||
className={styles.permissionModeInput}
|
||||
testId="permission-view-mode-json"
|
||||
>
|
||||
JSON
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className={styles.permissionContent}>
|
||||
{viewMode === 'list' ? (
|
||||
<PermissionOverview
|
||||
roleId={roleId ?? ''}
|
||||
expandedResources={expandedResources}
|
||||
onExpandedResourcesChange={setExpandedResources}
|
||||
/>
|
||||
) : role ? (
|
||||
<ReadOnlyJsonViewer permissions={transformApiToRolePermissions(role)} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
viewMode,
|
||||
handleModeChange,
|
||||
roleId,
|
||||
role,
|
||||
expandedResources,
|
||||
setExpandedResources,
|
||||
],
|
||||
);
|
||||
|
||||
if (!hasReadPermission && !isAuthZLoading) {
|
||||
return (
|
||||
<PermissionDeniedFullPage permissionName={readRolePermission.object} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRolesEnabled && !isFeatureGateLoading) {
|
||||
return (
|
||||
<div className={styles.viewRolePage} data-testid="view-role-page">
|
||||
<div className={styles.viewRolePageHeader}>
|
||||
<div className={styles.viewRolePageHeaderLeft}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={handleCancel}
|
||||
data-testid="cancel-button"
|
||||
className={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
<Typography.Title level={3}>View Role</Typography.Title>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorInPlace
|
||||
error={
|
||||
new APIError({
|
||||
httpStatusCode: 403,
|
||||
error: {
|
||||
code: 'FEATURE_DISABLED',
|
||||
message:
|
||||
'Custom roles feature is not available. Please check your license or feature configuration.',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
data-testid="feature-gate-error-banner"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthZLoading || isLoading || isFeatureGateLoading) {
|
||||
return (
|
||||
<div className={styles.viewRolePage}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.viewRolePage} data-testid="view-role-page">
|
||||
<div className={styles.viewRolePageHeader}>
|
||||
<div className={styles.viewRolePageHeaderLeft}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={handleCancel}
|
||||
data-testid="cancel-button"
|
||||
className={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
<Typography.Title level={3}>Failed to load role</Typography.Title>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorInPlace
|
||||
error={toAPIError(error, 'Failed to load role details')}
|
||||
data-testid="role-error-banner"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.viewRolePage} data-testid="view-role-page">
|
||||
<div className={styles.viewRolePageHeader}>
|
||||
<div className={styles.viewRolePageHeaderLeft}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={handleCancel}
|
||||
data-testid="cancel-button"
|
||||
className={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
<Typography.Title level={3}>
|
||||
{'Role - ' + role.name || 'Loading role...'}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<div className={styles.viewRolePageActions}>
|
||||
<TooltipSimple
|
||||
title={isDeleteDisabled ? deleteDisabledReason : 'Open delete modal'}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={handleOpenDeleteModal}
|
||||
disabled={isDeleteDisabled}
|
||||
data-testid="delete-button"
|
||||
className={styles.deleteButton}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
<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
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.viewRolePageContent}>
|
||||
<div className={styles.viewRolePageForm}>
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-description" className={styles.formLabel}>
|
||||
Description
|
||||
</label>
|
||||
<Typography>{role.description}</Typography>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-created-at" className={styles.formLabel}>
|
||||
Created At
|
||||
</label>
|
||||
<Badge color="secondary">
|
||||
{formatTimezoneAdjustedTimestampOptional(role.createdAt)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-modified-at" className={styles.formLabel}>
|
||||
Last Modified At
|
||||
</label>
|
||||
<Badge color="secondary">
|
||||
{formatTimezoneAdjustedTimestampOptional(role.updatedAt)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tabs
|
||||
className={styles.roleTabs}
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
items={tabItems}
|
||||
/>
|
||||
</div>
|
||||
<DeleteRoleModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
roleName={role.name}
|
||||
error={deleteError}
|
||||
onCancel={handleCloseDeleteModal}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewRolePage;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import { render, screen, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
|
||||
import {
|
||||
buildViewRoleRoute,
|
||||
CUSTOM_ROLE_ID,
|
||||
CUSTOM_ROLE_NAME,
|
||||
mockHooksForCustomRole,
|
||||
} from './testUtils';
|
||||
|
||||
describe('ViewRolePage - Actions', () => {
|
||||
beforeEach(() => {
|
||||
mockHooksForCustomRole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('navigates to roles list when Cancel clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Switch>
|
||||
<Route path="/settings/roles/:roleId">
|
||||
<ViewRolePage />
|
||||
</Route>
|
||||
<Route path="/settings/roles">
|
||||
<div data-testid="roles-list-target" />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
|
||||
);
|
||||
|
||||
const cancelBtn = screen.getByTestId('cancel-button');
|
||||
await user.click(cancelBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('roles-list-target'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to edit page when Update clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Switch>
|
||||
<Route path="/settings/roles/:roleId/edit">
|
||||
<div data-testid="edit-page-target" />
|
||||
</Route>
|
||||
<Route path="/settings/roles/:roleId">
|
||||
<ViewRolePage />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
|
||||
);
|
||||
|
||||
const updateBtn = screen.getByTestId('save-button');
|
||||
await user.click(updateBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('edit-page-target'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens delete modal when Delete clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
const deleteBtn = screen.getByTestId('delete-button');
|
||||
await user.click(deleteBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByText(/Are you sure you want to delete the role/),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls delete API and redirects on successful delete', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const mockDeleteRole = jest.fn().mockResolvedValue({});
|
||||
jest.spyOn(roleApi, 'useDeleteRole').mockReturnValue({
|
||||
mutateAsync: mockDeleteRole,
|
||||
} as unknown as ReturnType<typeof roleApi.useDeleteRole>);
|
||||
|
||||
render(
|
||||
<Switch>
|
||||
<Route path="/settings/roles/:roleId">
|
||||
<ViewRolePage />
|
||||
</Route>
|
||||
<Route path="/settings/roles">
|
||||
<div data-testid="roles-list-target" />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('delete-button'));
|
||||
|
||||
await expect(
|
||||
screen.findByText(/Are you sure you want to delete the role/),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const modal = screen.getByRole('dialog');
|
||||
const modalConfirmBtn = within(modal).getByRole('button', {
|
||||
name: /Delete Role/i,
|
||||
});
|
||||
await user.click(modalConfirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteRole).toHaveBeenCalledWith({
|
||||
pathParams: { id: CUSTOM_ROLE_ID },
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('roles-list-target'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
customRoleResponse,
|
||||
managedRoleResponse,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import {
|
||||
mockUseAuthZDenyAll,
|
||||
mockUseAuthZGrantAll,
|
||||
mockUseAuthZGrantByPrefix,
|
||||
} from 'tests/authz-test-utils';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
|
||||
import {
|
||||
buildViewRoleRoute,
|
||||
CUSTOM_ROLE_ID,
|
||||
CUSTOM_ROLE_NAME,
|
||||
MANAGED_ROLE_ID,
|
||||
MANAGED_ROLE_NAME,
|
||||
mockPermissionsData,
|
||||
} from './testUtils';
|
||||
|
||||
const mockUseAuthZGrantReadDeleteDenied = mockUseAuthZGrantByPrefix(
|
||||
'read',
|
||||
'update',
|
||||
);
|
||||
const mockUseAuthZGrantReadUpdateDenied = mockUseAuthZGrantByPrefix(
|
||||
'read',
|
||||
'delete',
|
||||
);
|
||||
|
||||
describe('ViewRolePage - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('permission denied', () => {
|
||||
it('shows permission denied page when read permission denied', async () => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZDenyAll);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText(/You are not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update button visibility', () => {
|
||||
it('enables Update button when update permission granted', () => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('save-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Update button when update permission denied', () => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantReadUpdateDenied);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('save-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Update button when role is managed', () => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: managedRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: {
|
||||
...mockPermissionsData,
|
||||
roleId: MANAGED_ROLE_ID,
|
||||
roleName: MANAGED_ROLE_NAME,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(MANAGED_ROLE_ID, MANAGED_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('save-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows managed role tooltip when update button hovered on managed role', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: managedRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: {
|
||||
...mockPermissionsData,
|
||||
roleId: MANAGED_ROLE_ID,
|
||||
roleName: MANAGED_ROLE_NAME,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(MANAGED_ROLE_ID, MANAGED_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
const updateButton = screen.getByTestId('save-button');
|
||||
await user.hover(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
'Managed roles cannot be updated',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
const updateButton = screen.getByTestId('save-button');
|
||||
await user.hover(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
/You are not authorized to perform/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete button visibility', () => {
|
||||
it('disables Delete button when delete permission denied', () => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantReadDeleteDenied);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('delete-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Delete button when delete permission granted', () => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('delete-button')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
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,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByTestId('delete-button');
|
||||
await user.hover(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
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();
|
||||
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: managedRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: {
|
||||
...mockPermissionsData,
|
||||
roleId: MANAGED_ROLE_ID,
|
||||
roleName: MANAGED_ROLE_NAME,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(MANAGED_ROLE_ID, MANAGED_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByTestId('delete-button');
|
||||
await user.hover(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
'Managed roles cannot be deleted',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows skeleton while checking permissions', () => {
|
||||
jest.spyOn(useAuthZModule, 'useAuthZ').mockReturnValue({
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
|
||||
import {
|
||||
buildViewRoleRoute,
|
||||
CUSTOM_ROLE_ID,
|
||||
CUSTOM_ROLE_NAME,
|
||||
mockHooksForCustomRole,
|
||||
} from './testUtils';
|
||||
|
||||
describe('ViewRolePage - Custom Role', () => {
|
||||
beforeEach(() => {
|
||||
mockHooksForCustomRole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders role name in page title', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('Role - billing-manager'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows role description', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('Custom role for managing billing and invoices.'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Update button for custom roles', () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('save-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Cancel button', () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Delete button', () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders created/updated timestamps labels', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expect(screen.findByText('Created At')).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Last Modified At'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
|
||||
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
|
||||
import {
|
||||
buildViewRoleRoute,
|
||||
CUSTOM_ROLE_ID,
|
||||
CUSTOM_ROLE_NAME,
|
||||
mockPermissionsData,
|
||||
} from './testUtils';
|
||||
|
||||
describe('ViewRolePage - Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows fallback for missing description', async () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: {
|
||||
status: 'success',
|
||||
data: {
|
||||
...customRoleResponse.data,
|
||||
description: '',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expect(screen.findByText('Description')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback for invalid timestamps', () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: {
|
||||
status: 'success',
|
||||
data: {
|
||||
...customRoleResponse.data,
|
||||
createdAt: 'invalid-date',
|
||||
updatedAt: 'also-invalid',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('shows fallback for undefined timestamps', () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: {
|
||||
status: 'success',
|
||||
data: {
|
||||
...customRoleResponse.data,
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
|
||||
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
|
||||
import {
|
||||
buildViewRoleRoute,
|
||||
CUSTOM_ROLE_ID,
|
||||
CUSTOM_ROLE_NAME,
|
||||
mockPermissionsData,
|
||||
} from './testUtils';
|
||||
|
||||
describe('ViewRolePage - Error State', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('displays error component when API has error but role data exists', () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error('Failed to fetch'),
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays error state with title when API fails without role data', async () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error('Failed to fetch role'),
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('Failed to load role'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows back button on error state', () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error('Failed to fetch role'),
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to roles list when back button clicked on error state', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error('Failed to fetch role'),
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
render(
|
||||
<Switch>
|
||||
<Route path="/settings/roles/:roleId">
|
||||
<ViewRolePage />
|
||||
</Route>
|
||||
<Route path="/settings/roles">
|
||||
<div data-testid="roles-list-target" />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByTestId('cancel-button');
|
||||
await user.click(cancelButton);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('roles-list-target'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
|
||||
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
|
||||
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
|
||||
import {
|
||||
buildViewRoleRoute,
|
||||
CUSTOM_ROLE_ID,
|
||||
CUSTOM_ROLE_NAME,
|
||||
} from './testUtils';
|
||||
|
||||
describe('ViewRolePage - Feature Gate', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('feature disabled', () => {
|
||||
it('shows error when fine-grained authz flag is inactive', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
appContextOverrides: {
|
||||
featureFlags: defaultFeatureFlags.map((f) =>
|
||||
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
|
||||
? { ...f, active: false }
|
||||
: f,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when license is invalid', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
appContextOverrides: { activeLicense: invalidLicense },
|
||||
});
|
||||
|
||||
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', () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
appContextOverrides: { activeLicense: invalidLicense },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('back button is enabled when feature disabled', () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
appContextOverrides: { activeLicense: invalidLicense },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
|
||||
import {
|
||||
buildViewRoleRoute,
|
||||
CUSTOM_ROLE_ID,
|
||||
CUSTOM_ROLE_NAME,
|
||||
} from './testUtils';
|
||||
|
||||
describe('ViewRolePage - Loading State', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows skeleton while fetching role', () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not fetch when roleId is missing from URL', () => {
|
||||
const getRole = jest.spyOn(roleApi, 'useGetRole');
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: '/settings/roles',
|
||||
});
|
||||
|
||||
expect(getRole).toHaveBeenCalledWith(
|
||||
{ id: '' },
|
||||
expect.objectContaining({ query: { enabled: false } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
|
||||
import {
|
||||
buildViewRoleRoute,
|
||||
MANAGED_ROLE_ID,
|
||||
MANAGED_ROLE_NAME,
|
||||
mockHooksForManagedRole,
|
||||
} from './testUtils';
|
||||
|
||||
describe('ViewRolePage - Managed Role', () => {
|
||||
beforeEach(() => {
|
||||
mockHooksForManagedRole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('disables Delete button for managed roles', () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(MANAGED_ROLE_ID, MANAGED_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('delete-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Update button for managed roles', () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(MANAGED_ROLE_ID, MANAGED_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('save-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('still shows Cancel button for managed roles', () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(MANAGED_ROLE_ID, MANAGED_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,679 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
|
||||
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, within } from 'tests/test-utils';
|
||||
|
||||
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
|
||||
import {
|
||||
buildViewRoleRoute,
|
||||
CUSTOM_ROLE_ID,
|
||||
CUSTOM_ROLE_NAME,
|
||||
mockHooksForCustomRole,
|
||||
mockHooksWithPermissions,
|
||||
mockPermissionsData,
|
||||
} from './testUtils';
|
||||
|
||||
async function expandAllCards(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const expandButton = screen.getByTestId('expand-all-button');
|
||||
await user.click(expandButton);
|
||||
}
|
||||
|
||||
describe('ViewRolePage - Permission Overview', () => {
|
||||
beforeEach(() => {
|
||||
mockHooksForCustomRole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders Transaction Groups section label', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('Transaction Groups'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders permission overview container', () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows resource permission cards', () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('resource-section-factor-api-key'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('resource-section-role')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('resource-section-serviceaccount'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays granted count for each resource', () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('granted-count-factor-api-key'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViewRolePage - Permission Overview Loading State', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows skeleton when permissions are loading', () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('permission-overview-loading')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViewRolePage - Permission Overview Error State', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows error when permissions fail to load', () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error('Failed'),
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('permission-overview-error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViewRolePage - Scope: ALL permissions', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows "All" badge for actions with ALL scope', async () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'factor-api-key',
|
||||
resourceKind: 'factor-api-key',
|
||||
resourceType: 'metaresource',
|
||||
resourceLabel: 'API Keys',
|
||||
actions: {
|
||||
read: { scope: 'all', selectedIds: [] },
|
||||
create: { scope: 'all', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['read', 'create'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expandAllCards();
|
||||
|
||||
expect(screen.getByTestId('scope-badge-read')).toHaveTextContent('All');
|
||||
expect(screen.getByTestId('scope-badge-create')).toHaveTextContent('All');
|
||||
});
|
||||
|
||||
it('shows full granted count when all actions are ALL', () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'role',
|
||||
resourceKind: 'role',
|
||||
resourceType: 'role',
|
||||
resourceLabel: 'Roles',
|
||||
actions: {
|
||||
read: { scope: 'all', selectedIds: [] },
|
||||
create: { scope: 'all', selectedIds: [] },
|
||||
update: { scope: 'all', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['read', 'create', 'update'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('granted-count-role')).toHaveTextContent(
|
||||
'3 / 3 granted',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViewRolePage - Scope: NONE permissions', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows "None" badge for actions with NONE scope', async () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'serviceaccount',
|
||||
resourceKind: 'serviceaccount',
|
||||
resourceType: 'serviceaccount',
|
||||
resourceLabel: 'Service Accounts',
|
||||
actions: {
|
||||
read: { scope: 'none', selectedIds: [] },
|
||||
delete: { scope: 'none', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['read', 'delete'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expandAllCards();
|
||||
|
||||
expect(screen.getByTestId('scope-badge-read')).toHaveTextContent('None');
|
||||
expect(screen.getByTestId('scope-badge-delete')).toHaveTextContent('None');
|
||||
});
|
||||
|
||||
it('shows zero granted count when all actions are NONE', () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'factor-api-key',
|
||||
resourceKind: 'factor-api-key',
|
||||
resourceType: 'metaresource',
|
||||
resourceLabel: 'API Keys',
|
||||
actions: {
|
||||
read: { scope: 'none', selectedIds: [] },
|
||||
create: { scope: 'none', selectedIds: [] },
|
||||
update: { scope: 'none', selectedIds: [] },
|
||||
delete: { scope: 'none', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['read', 'create', 'update', 'delete'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
|
||||
'0 / 4 granted',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows "Only selected" badge with count', async () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'role',
|
||||
resourceKind: 'role',
|
||||
resourceType: 'role',
|
||||
resourceLabel: 'Roles',
|
||||
actions: {
|
||||
read: {
|
||||
scope: 'only_selected',
|
||||
selectedIds: ['admin', 'editor', 'viewer'],
|
||||
},
|
||||
},
|
||||
availableActions: ['read'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expandAllCards();
|
||||
|
||||
expect(screen.getByTestId('scope-badge-read')).toHaveTextContent(
|
||||
'Only selected · 3',
|
||||
);
|
||||
});
|
||||
|
||||
it('displays selected IDs as expandable chips', async () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'factor-api-key',
|
||||
resourceKind: 'factor-api-key',
|
||||
resourceType: 'metaresource',
|
||||
resourceLabel: 'API Keys',
|
||||
actions: {
|
||||
read: {
|
||||
scope: 'only_selected',
|
||||
selectedIds: ['key-abc-123', 'key-def-456'],
|
||||
},
|
||||
},
|
||||
availableActions: ['read'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expandAllCards();
|
||||
|
||||
await expect(screen.findByText('key-abc-123')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('key-def-456')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('counts ONLY_SELECTED as granted in count', () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'serviceaccount',
|
||||
resourceKind: 'serviceaccount',
|
||||
resourceType: 'serviceaccount',
|
||||
resourceLabel: 'Service Accounts',
|
||||
actions: {
|
||||
read: { scope: 'only_selected', selectedIds: ['sa-1'] },
|
||||
create: { scope: 'none', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['read', 'create'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('granted-count-serviceaccount')).toHaveTextContent(
|
||||
'1 / 2 granted',
|
||||
);
|
||||
});
|
||||
|
||||
it('can collapse and expand selected items list', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'role',
|
||||
resourceKind: 'role',
|
||||
resourceType: 'role',
|
||||
resourceLabel: 'Roles',
|
||||
actions: {
|
||||
update: {
|
||||
scope: 'only_selected',
|
||||
selectedIds: ['editor-role'],
|
||||
},
|
||||
},
|
||||
availableActions: ['update'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expandAllCards();
|
||||
|
||||
await expect(screen.findByText('editor-role')).resolves.toBeInTheDocument();
|
||||
|
||||
const toggle = screen.getByTestId('toggle-items-update');
|
||||
await user.click(toggle);
|
||||
|
||||
expect(screen.queryByText('editor-role')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(toggle);
|
||||
await expect(screen.findByText('editor-role')).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViewRolePage - Mixed permission scopes', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders all three scope types in single resource card', async () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'factor-api-key',
|
||||
resourceKind: 'factor-api-key',
|
||||
resourceType: 'metaresource',
|
||||
resourceLabel: 'API Keys',
|
||||
actions: {
|
||||
read: { scope: 'all', selectedIds: [] },
|
||||
create: { scope: 'none', selectedIds: [] },
|
||||
update: { scope: 'only_selected', selectedIds: ['key-1', 'key-2'] },
|
||||
delete: { scope: 'none', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['read', 'create', 'update', 'delete'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expandAllCards();
|
||||
|
||||
const section = screen.getByTestId('resource-section-factor-api-key');
|
||||
|
||||
expect(within(section).getByTestId('scope-badge-read')).toHaveTextContent(
|
||||
'All',
|
||||
);
|
||||
expect(within(section).getByTestId('scope-badge-create')).toHaveTextContent(
|
||||
'None',
|
||||
);
|
||||
expect(within(section).getByTestId('scope-badge-update')).toHaveTextContent(
|
||||
'Only selected · 2',
|
||||
);
|
||||
expect(within(section).getByTestId('scope-badge-delete')).toHaveTextContent(
|
||||
'None',
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
|
||||
'2 / 4 granted',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders multiple resources with different scope combinations', () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'factor-api-key',
|
||||
resourceKind: 'factor-api-key',
|
||||
resourceType: 'metaresource',
|
||||
resourceLabel: 'API Keys',
|
||||
actions: {
|
||||
read: { scope: 'all', selectedIds: [] },
|
||||
create: { scope: 'all', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['read', 'create'],
|
||||
},
|
||||
{
|
||||
resourceId: 'role',
|
||||
resourceKind: 'role',
|
||||
resourceType: 'role',
|
||||
resourceLabel: 'Roles',
|
||||
actions: {
|
||||
read: { scope: 'none', selectedIds: [] },
|
||||
create: { scope: 'none', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['read', 'create'],
|
||||
},
|
||||
{
|
||||
resourceId: 'serviceaccount',
|
||||
resourceKind: 'serviceaccount',
|
||||
resourceType: 'serviceaccount',
|
||||
resourceLabel: 'Service Accounts',
|
||||
actions: {
|
||||
read: { scope: 'only_selected', selectedIds: ['sa-1'] },
|
||||
create: { scope: 'all', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['read', 'create'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
|
||||
'2 / 2 granted',
|
||||
);
|
||||
expect(screen.getByTestId('granted-count-role')).toHaveTextContent(
|
||||
'0 / 2 granted',
|
||||
);
|
||||
expect(screen.getByTestId('granted-count-serviceaccount')).toHaveTextContent(
|
||||
'2 / 2 granted',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViewRolePage - Unknown resources', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders unknown resource with fallback label', async () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'future-resource',
|
||||
resourceKind: 'future-resource',
|
||||
resourceType: 'metaresource',
|
||||
resourceLabel: 'future-resource',
|
||||
actions: {
|
||||
read: { scope: 'all', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['read'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('resource-section-future-resource'),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('future-resource'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows raw verb name when no label mapping exists', async () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'test-resource',
|
||||
resourceKind: 'test-resource',
|
||||
resourceType: 'metaresource',
|
||||
resourceLabel: 'Test Resource',
|
||||
actions: {
|
||||
unknown_action: { scope: 'all', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['unknown_action'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expandAllCards();
|
||||
|
||||
await expect(
|
||||
screen.findByText('Unknown_action'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles resource with empty actions', () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'empty-resource',
|
||||
resourceKind: 'empty-resource',
|
||||
resourceType: 'metaresource',
|
||||
resourceLabel: 'Empty Resource',
|
||||
actions: {},
|
||||
availableActions: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('resource-section-empty-resource'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('granted-count-empty-resource')).toHaveTextContent(
|
||||
'0 / 0 granted',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViewRolePage - View mode toggle', () => {
|
||||
beforeEach(() => {
|
||||
mockHooksForCustomRole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders Interactive/JSON toggle', () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('permission-view-mode-list')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('permission-view-mode-json')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to JSON view when JSON toggle clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
|
||||
|
||||
const jsonToggle = screen.getByTestId('permission-view-mode-json');
|
||||
await user.click(jsonToggle);
|
||||
|
||||
expect(screen.queryByTestId('permission-overview')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViewRolePage - JSON Viewer Copy Button', () => {
|
||||
beforeEach(() => {
|
||||
mockHooksForCustomRole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders copy button in JSON view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
const jsonToggle = screen.getByTestId('permission-view-mode-json');
|
||||
await user.click(jsonToggle);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('read-only-json-viewer-copy-button'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copy button is clickable', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
const jsonToggle = screen.getByTestId('permission-view-mode-json');
|
||||
await user.click(jsonToggle);
|
||||
|
||||
const copyButton = screen.getByTestId('read-only-json-viewer-copy-button');
|
||||
expect(copyButton).not.toBeDisabled();
|
||||
await user.click(copyButton);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
CoretypesKindDTO,
|
||||
CoretypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
customRoleResponse,
|
||||
managedRoleResponse,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
|
||||
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
|
||||
|
||||
export const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
|
||||
export const CUSTOM_ROLE_NAME = 'billing-manager';
|
||||
export const MANAGED_ROLE_ID = '019c24aa-2248-756f-9833-984f1ab63819';
|
||||
export const MANAGED_ROLE_NAME = 'signoz-admin';
|
||||
|
||||
export function buildViewRoleRoute(roleId: string, roleName: string): string {
|
||||
return `/settings/roles/${roleId}?name=${encodeURIComponent(roleName)}`;
|
||||
}
|
||||
|
||||
export const mockPermissionsData = {
|
||||
roleId: CUSTOM_ROLE_ID,
|
||||
roleName: 'billing-manager',
|
||||
roleDescription: 'Custom role for managing billing and invoices.',
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'factor-api-key',
|
||||
resourceKind: CoretypesKindDTO['factor-api-key'],
|
||||
resourceType: CoretypesTypeDTO.metaresource,
|
||||
resourceLabel: 'API Keys',
|
||||
actions: {
|
||||
create: { scope: 'none', selectedIds: [] },
|
||||
read: { scope: 'all', selectedIds: [] },
|
||||
},
|
||||
availableActions: ['create', 'read', 'update', 'delete', 'list'],
|
||||
},
|
||||
{
|
||||
resourceId: 'role',
|
||||
resourceKind: CoretypesKindDTO.role,
|
||||
resourceType: CoretypesTypeDTO.role,
|
||||
resourceLabel: 'Roles',
|
||||
actions: {
|
||||
create: { scope: 'none', selectedIds: [] },
|
||||
read: { scope: 'none', selectedIds: [] },
|
||||
},
|
||||
availableActions: [
|
||||
'create',
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
'list',
|
||||
'attach',
|
||||
'detach',
|
||||
],
|
||||
},
|
||||
{
|
||||
resourceId: 'serviceaccount',
|
||||
resourceKind: CoretypesKindDTO.serviceaccount,
|
||||
resourceType: CoretypesTypeDTO.serviceaccount,
|
||||
resourceLabel: 'Service Accounts',
|
||||
actions: {
|
||||
create: { scope: 'none', selectedIds: [] },
|
||||
read: { scope: 'none', selectedIds: [] },
|
||||
},
|
||||
availableActions: [
|
||||
'create',
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
'list',
|
||||
'attach',
|
||||
'detach',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function mockHooksForCustomRole(): void {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
}
|
||||
|
||||
export function mockHooksWithPermissions(permissions: unknown): void {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: permissions,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
}
|
||||
|
||||
export function mockHooksForManagedRole(): void {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: managedRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: {
|
||||
...mockPermissionsData,
|
||||
roleId: MANAGED_ROLE_ID,
|
||||
roleName: 'signoz-admin',
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-5) 0;
|
||||
}
|
||||
|
||||
.rowHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.rowLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--l3-foreground);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--spacing-1);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
border-radius: var(--spacing-2);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.allBadgeVariant {
|
||||
color: var(--bg-robin-300);
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 16%, transparent);
|
||||
}
|
||||
|
||||
.noneBadgeVariant {
|
||||
color: var(--l3-foreground);
|
||||
background: color-mix(in srgb, var(--l3-foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.selectedBadgeVariant {
|
||||
color: var(--bg-sienna-400);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 16%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { getScopeBadge, ScopeBadgeVariant } from './permissionDisplay.utils';
|
||||
import SelectedItemsChips from './SelectedItemsChips';
|
||||
|
||||
import styles from './ActionRow.module.scss';
|
||||
import { PermissionScope } from 'container/RolesSettings/types';
|
||||
|
||||
const BADGE_VARIANT_CLASS: Record<ScopeBadgeVariant, string> = {
|
||||
[ScopeBadgeVariant.ALL]: styles.allBadgeVariant,
|
||||
[ScopeBadgeVariant.NONE]: styles.noneBadgeVariant,
|
||||
[ScopeBadgeVariant.SELECTED]: styles.selectedBadgeVariant,
|
||||
};
|
||||
|
||||
export interface ActionRowProps {
|
||||
actionName: string;
|
||||
actionLabel: string;
|
||||
scope: PermissionScope;
|
||||
selectedIds?: string[];
|
||||
}
|
||||
|
||||
function ActionRow({
|
||||
actionName,
|
||||
actionLabel,
|
||||
scope,
|
||||
selectedIds = [],
|
||||
}: ActionRowProps): JSX.Element {
|
||||
const isExpandable =
|
||||
scope === PermissionScope.ONLY_SELECTED && selectedIds.length > 0;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(isExpandable);
|
||||
|
||||
const handleToggle = useCallback((): void => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const badge = getScopeBadge(scope, selectedIds.length);
|
||||
|
||||
return (
|
||||
<div className={styles.row} data-testid={`permission-row-${actionName}`}>
|
||||
<div className={styles.rowHeader}>
|
||||
<div className={styles.rowLeft}>
|
||||
{isExpandable && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chevron}
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} selected items`}
|
||||
data-testid={`toggle-items-${actionName}`}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
)}
|
||||
<Typography as="span" size="base">
|
||||
{actionLabel}
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography
|
||||
as="span"
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={`${styles.badge} ${BADGE_VARIANT_CLASS[badge.variant]}`}
|
||||
testId={`scope-badge-${actionName}`}
|
||||
>
|
||||
{badge.label}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{isExpanded && isExpandable && (
|
||||
<SelectedItemsChips
|
||||
ids={selectedIds}
|
||||
testId={`selected-items-${actionName}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionRow;
|
||||
@@ -0,0 +1,21 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.collapseAction {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button, ButtonGroup } from '@signozhq/ui/button';
|
||||
import { Skeleton } from 'antd';
|
||||
|
||||
import { useRolePermissions } from '../../hooks/useRolePermissions';
|
||||
|
||||
import ResourcePermissionCard from './ResourcePermissionCard';
|
||||
|
||||
import styles from './PermissionOverview.module.scss';
|
||||
|
||||
export interface PermissionOverviewProps {
|
||||
roleId: string;
|
||||
expandedResources?: Set<string>;
|
||||
onExpandedResourcesChange?: (expanded: Set<string>) => void;
|
||||
}
|
||||
|
||||
function PermissionOverview({
|
||||
roleId,
|
||||
expandedResources: externalExpanded,
|
||||
onExpandedResourcesChange,
|
||||
}: PermissionOverviewProps): JSX.Element {
|
||||
const { data: permissions, isLoading, isError } = useRolePermissions(roleId);
|
||||
const [internalExpanded, setInternalExpanded] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const isControlled = externalExpanded !== undefined;
|
||||
const expandedResources = isControlled ? externalExpanded : internalExpanded;
|
||||
|
||||
const updateExpandedResources = useCallback(
|
||||
(updater: (prev: Set<string>) => Set<string>): void => {
|
||||
if (isControlled) {
|
||||
onExpandedResourcesChange?.(updater(externalExpanded));
|
||||
} else {
|
||||
setInternalExpanded(updater);
|
||||
}
|
||||
},
|
||||
[isControlled, externalExpanded, onExpandedResourcesChange],
|
||||
);
|
||||
|
||||
const resources = useMemo(
|
||||
() => permissions?.resources ?? [],
|
||||
[permissions?.resources],
|
||||
);
|
||||
|
||||
const handleExpandAll = useCallback((): void => {
|
||||
updateExpandedResources(() => new Set(resources.map((r) => r.resourceId)));
|
||||
}, [resources, updateExpandedResources]);
|
||||
|
||||
const handleCollapseAll = useCallback((): void => {
|
||||
updateExpandedResources(() => new Set());
|
||||
}, [updateExpandedResources]);
|
||||
|
||||
const handleExpandChange = useCallback(
|
||||
(resourceId: string) =>
|
||||
(expanded: boolean): void => {
|
||||
updateExpandedResources((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (expanded) {
|
||||
next.add(resourceId);
|
||||
} else {
|
||||
next.delete(resourceId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[updateExpandedResources],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.container} data-testid="permission-overview-loading">
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !permissions) {
|
||||
return (
|
||||
<div className={styles.container} data-testid="permission-overview-error">
|
||||
<Typography.Text className={styles.errorText} align="center" color="danger">
|
||||
Failed to load permissions
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-testid="permission-overview">
|
||||
<div className={styles.collapseAction}>
|
||||
<ButtonGroup
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
testId="toggle-all-group"
|
||||
>
|
||||
<Button onClick={handleExpandAll} data-testid="expand-all-button">
|
||||
Expand all
|
||||
</Button>
|
||||
<Button onClick={handleCollapseAll} data-testid="collapse-all-button">
|
||||
Collapse all
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{resources.map((resource) => (
|
||||
<ResourcePermissionCard
|
||||
key={resource.resourceId}
|
||||
resource={resource}
|
||||
isExpanded={expandedResources.has(resource.resourceId)}
|
||||
onExpandChange={handleExpandChange(resource.resourceId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionOverview;
|
||||
@@ -0,0 +1,78 @@
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: var(--spacing-4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-5);
|
||||
width: 100%;
|
||||
padding: var(--spacing-6) var(--spacing-7);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--l1-background-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grantedCount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-1) var(--spacing-4);
|
||||
color: var(--bg-robin-300);
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 14%, transparent);
|
||||
border-radius: 16px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 var(--spacing-7);
|
||||
|
||||
// A subtle divider between consecutive action rows.
|
||||
> * + * {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
import { getResourcePanel } from '../../permissions.config';
|
||||
import { useRoleGrantedCount } from '../../hooks/useRoleGrantedCount';
|
||||
import { PermissionScope, ResourcePermissions } from '../../types';
|
||||
|
||||
import ActionRow from './ActionRow';
|
||||
import { getActionLabel } from './permissionDisplay.utils';
|
||||
|
||||
import styles from './ResourcePermissionCard.module.scss';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
export interface ResourcePermissionCardProps {
|
||||
resource: ResourcePermissions;
|
||||
isExpanded?: boolean;
|
||||
onExpandChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
function ResourcePermissionCard({
|
||||
resource,
|
||||
isExpanded: controlledExpanded,
|
||||
onExpandChange,
|
||||
}: ResourcePermissionCardProps): JSX.Element {
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const isControlled = controlledExpanded !== undefined;
|
||||
const isExpanded = isControlled ? controlledExpanded : internalExpanded;
|
||||
|
||||
const { resourceLabel, resourceKind, actions, availableActions } = resource;
|
||||
|
||||
const panel = getResourcePanel(resourceKind);
|
||||
const Icon = panel.icon;
|
||||
|
||||
const handleToggleExpand = useCallback((): void => {
|
||||
if (isControlled) {
|
||||
onExpandChange?.(!controlledExpanded);
|
||||
} else {
|
||||
setInternalExpanded((prev) => !prev);
|
||||
}
|
||||
}, [isControlled, controlledExpanded, onExpandChange]);
|
||||
|
||||
const [grantedCount, totalCount] = useRoleGrantedCount(resource);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={styles.card}
|
||||
data-testid={`resource-section-${resourceKind}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.header}
|
||||
onClick={handleToggleExpand}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${resourceLabel}: ${grantedCount} of ${totalCount} permissions granted`}
|
||||
data-testid={`resource-card-header-${resourceKind}`}
|
||||
>
|
||||
<div className={styles.headerLeft}>
|
||||
<span className={styles.chevron}>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</span>
|
||||
<span className={styles.icon}>
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<Typography as="h4" size="base" weight="bold">
|
||||
{resourceLabel}
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography
|
||||
as="span"
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.grantedCount}
|
||||
testId={`granted-count-${resourceKind}`}
|
||||
>
|
||||
{grantedCount} / {totalCount} granted
|
||||
</Typography>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className={styles.rows}>
|
||||
{availableActions.map((actionName) => {
|
||||
const config = actions[actionName];
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedIds =
|
||||
config.scope === PermissionScope.ONLY_SELECTED ? config.selectedIds : [];
|
||||
|
||||
return (
|
||||
<ActionRow
|
||||
key={actionName}
|
||||
actionName={actionName}
|
||||
actionLabel={getActionLabel(actionName)}
|
||||
scope={config.scope}
|
||||
selectedIds={selectedIds}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResourcePermissionCard;
|
||||
@@ -0,0 +1,8 @@
|
||||
.chips {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 calc(14px + var(--spacing-3));
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import styles from './SelectedItemsChips.module.scss';
|
||||
import { useId } from 'react';
|
||||
|
||||
export interface SelectedItemsChipsProps {
|
||||
ids: string[];
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function SelectedItemsChips({
|
||||
ids,
|
||||
testId,
|
||||
}: SelectedItemsChipsProps): JSX.Element {
|
||||
const componentId = useId();
|
||||
|
||||
return (
|
||||
<ul className={styles.chips} data-testid={testId}>
|
||||
{ids.map((id) => (
|
||||
<Badge
|
||||
key={`selector-badge-${componentId}-${id}`}
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
>
|
||||
{id}
|
||||
</Badge>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectedItemsChips;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { PermissionScope } from '../../types';
|
||||
|
||||
export enum ScopeBadgeVariant {
|
||||
ALL = 'all',
|
||||
NONE = 'none',
|
||||
SELECTED = 'selected',
|
||||
}
|
||||
|
||||
export interface ScopeBadge {
|
||||
label: string;
|
||||
variant: ScopeBadgeVariant;
|
||||
}
|
||||
|
||||
export function getActionLabel(actionName: string): string {
|
||||
if (!actionName) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
return actionName[0].toUpperCase() + actionName.slice(1);
|
||||
}
|
||||
|
||||
export function getScopeBadge(
|
||||
scope: PermissionScope,
|
||||
selectedCount: number,
|
||||
): ScopeBadge {
|
||||
switch (scope) {
|
||||
case PermissionScope.ALL:
|
||||
return { label: 'All', variant: ScopeBadgeVariant.ALL };
|
||||
case PermissionScope.ONLY_SELECTED:
|
||||
return {
|
||||
label: `Only selected · ${selectedCount}`,
|
||||
variant: ScopeBadgeVariant.SELECTED,
|
||||
};
|
||||
case PermissionScope.NONE:
|
||||
default:
|
||||
return { label: 'None', variant: ScopeBadgeVariant.NONE };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ViewRolePage';
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { matchPath, useHistory, useLocation } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
|
||||
export type ViewMode = 'list' | 'json';
|
||||
export type TabKey = 'overview';
|
||||
|
||||
const VIEW_MODES: ViewMode[] = ['list', 'json'];
|
||||
const TAB_KEYS: TabKey[] = ['overview'];
|
||||
|
||||
interface UseViewRolePageCallbacksResult {
|
||||
roleId: string | undefined;
|
||||
roleName: string;
|
||||
activeTab: TabKey;
|
||||
viewMode: ViewMode;
|
||||
expandedResources: Set<string>;
|
||||
setExpandedResources: (resources: Set<string>) => void;
|
||||
handleRedirectToUpdate: () => void;
|
||||
handleCancel: () => void;
|
||||
handleModeChange: (value: string) => void;
|
||||
handleTabChange: (key: string) => void;
|
||||
}
|
||||
|
||||
export function useViewRolePageActions(): UseViewRolePageCallbacksResult {
|
||||
const { pathname } = useLocation();
|
||||
const history = useHistory();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const match = matchPath<{ roleId: string }>(pathname, {
|
||||
path: ROUTES.ROLE_DETAILS,
|
||||
});
|
||||
const roleId = match?.params?.roleId;
|
||||
const roleName = urlQuery.get('name') ?? '';
|
||||
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
'tab',
|
||||
parseAsStringLiteral(TAB_KEYS).withDefault('overview'),
|
||||
);
|
||||
const [viewMode, setViewMode] = useQueryState(
|
||||
'viewMode',
|
||||
parseAsStringLiteral(VIEW_MODES).withDefault('list'),
|
||||
);
|
||||
const [expandedResources, setExpandedResources] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const handleRedirectToUpdate = useCallback(() => {
|
||||
if (!roleId || !roleName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateUrl = `${ROUTES.ROLE_EDIT.replace(':roleId', roleId)}?name=${roleName}`;
|
||||
history.push(updateUrl);
|
||||
}, [history, roleId, roleName]);
|
||||
|
||||
const handleCancel = useCallback((): void => {
|
||||
history.push(ROUTES.ROLES_SETTINGS);
|
||||
}, [history]);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(value: string): void => {
|
||||
void setViewMode(value as ViewMode);
|
||||
},
|
||||
[setViewMode],
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(key: string): void => {
|
||||
void setActiveTab(key as TabKey);
|
||||
},
|
||||
[setActiveTab],
|
||||
);
|
||||
|
||||
return {
|
||||
roleId,
|
||||
roleName,
|
||||
activeTab,
|
||||
viewMode,
|
||||
expandedResources,
|
||||
setExpandedResources,
|
||||
handleRedirectToUpdate,
|
||||
handleCancel,
|
||||
handleModeChange,
|
||||
handleTabChange,
|
||||
};
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
defaultFeatureFlags,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
} from 'tests/test-utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
@@ -36,13 +36,13 @@ describe('RolesSettings', () => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the header and search input', () => {
|
||||
it('renders the header and search input', async () => {
|
||||
render(<RolesSettings />);
|
||||
|
||||
expect(screen.getByText('Roles')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Create and manage custom roles for your team.'),
|
||||
).toBeInTheDocument();
|
||||
await expect(screen.findByText('Roles')).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Create and manage custom roles for your team.'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search for roles...'),
|
||||
).toBeInTheDocument();
|
||||
@@ -54,36 +54,41 @@ describe('RolesSettings', () => {
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
// Section headers
|
||||
expect(screen.getByText('Managed roles')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom roles')).toBeInTheDocument();
|
||||
await expect(screen.findByText('Managed roles')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('Custom roles')).resolves.toBeInTheDocument();
|
||||
|
||||
// Managed roles
|
||||
expect(screen.getByText('signoz-admin')).toBeInTheDocument();
|
||||
expect(screen.getByText('signoz-editor')).toBeInTheDocument();
|
||||
expect(screen.getByText('signoz-viewer')).toBeInTheDocument();
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('signoz-editor')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('signoz-viewer')).resolves.toBeInTheDocument();
|
||||
|
||||
// Custom roles
|
||||
expect(screen.getByText('billing-manager')).toBeInTheDocument();
|
||||
expect(screen.getByText('dashboard-creator')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('billing-manager'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('dashboard-creator'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// Custom roles count badge
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
await expect(screen.findByText('2')).resolves.toBeInTheDocument();
|
||||
|
||||
// Column headers
|
||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||
expect(screen.getByText('Updated At')).toBeInTheDocument();
|
||||
expect(screen.getByText('Created At')).toBeInTheDocument();
|
||||
await expect(screen.findByText('Name')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('Description')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('Updated At')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('Created At')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters roles by search query on name', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'billing' },
|
||||
});
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, 'billing');
|
||||
|
||||
await expect(
|
||||
screen.findByText('billing-manager'),
|
||||
@@ -94,13 +99,14 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('filters roles by search query on description', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'read-only' },
|
||||
});
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, 'read-only');
|
||||
|
||||
await expect(screen.findByText('signoz-viewer')).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
|
||||
@@ -108,13 +114,14 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('shows empty state when search matches nothing', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'nonexistentrole' },
|
||||
});
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, 'nonexistentrole');
|
||||
|
||||
await expect(
|
||||
screen.findByText('No roles match your search.'),
|
||||
@@ -177,7 +184,9 @@ describe('RolesSettings', () => {
|
||||
|
||||
for (const role of allRoles) {
|
||||
if (role.description) {
|
||||
expect(screen.getByText(role.description)).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(role.description),
|
||||
).resolves.toBeInTheDocument();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user