mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-25 09:30:31 +01:00
Compare commits
23 Commits
feat/docs/
...
feat/panel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94b20437ed | ||
|
|
d282af479d | ||
|
|
6d2c63eaa9 | ||
|
|
2814abf6df | ||
|
|
d682986b62 | ||
|
|
23ed1911ee | ||
|
|
832ea8d103 | ||
|
|
33ffc2f8bd | ||
|
|
c03a3e07e7 | ||
|
|
a5fd968ec5 | ||
|
|
07c3ad9a7d | ||
|
|
6b4fb53b99 | ||
|
|
ce5f911e5c | ||
|
|
b2646e18cc | ||
|
|
860120868f | ||
|
|
4dc0695e9d | ||
|
|
6c67274ecb | ||
|
|
48fe7dcb3f | ||
|
|
5371ad6d3e | ||
|
|
0bdae40748 | ||
|
|
673af34f0c | ||
|
|
e72ad29285 | ||
|
|
b598ddc220 |
17
.github/workflows/goci.yaml
vendored
17
.github/workflows/goci.yaml
vendored
@@ -140,20 +140,3 @@ 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,15 +6,12 @@ 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 = "frontend/src/schemas/generated/webSettings.schema.json"
|
||||
|
||||
const transactionGroupsSchemaPath = "frontend/src/schemas/generated/transactionGroups.schema.json"
|
||||
const webSettingsSchemaPath = "docs/config/web-settings.json"
|
||||
|
||||
func registerGenerateConfig(parentCmd *cobra.Command) {
|
||||
configCmd := &cobra.Command{
|
||||
@@ -30,14 +27,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -63,7 +52,6 @@ func generateWebSettings() error {
|
||||
return err
|
||||
}
|
||||
|
||||
schema.WithTitle("WebSettings")
|
||||
data, err := json.MarshalIndent(schema, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -71,31 +59,3 @@ 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,76 +1,48 @@
|
||||
# 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.
|
||||
# Migrating from the install script to Foundry
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 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].
|
||||
> The install script is now deprecated and will no longer receive updates.
|
||||
|
||||
## How it works
|
||||
This guide walks you through migrating an existing SigNoz deployment running via
|
||||
Docker Compose to [Foundry](https://signoz.io/docs/install/docker/).
|
||||
|
||||
Foundry splits a deployment into two commands:
|
||||
> [!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.
|
||||
|
||||
- `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.
|
||||
## Overview
|
||||
To stay up to date on new installation platforms and patterns, please refer to [Foundry](https://github.com/SigNoz/foundry).
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
## Prerequisites
|
||||
- [ ] Install Foundry - `curl -fsSL https://signoz.io/foundry.sh | bash`
|
||||
|
||||
- An existing SigNoz deployment from `install.sh` or `deploy/` (Compose or
|
||||
Swarm).
|
||||
- `foundryctl` (installed in step 1).
|
||||
## 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.
|
||||
|
||||
## Migrate
|
||||
1. Make a note of the volume names used by your existing deployment for the following components:
|
||||
- ClickHouse
|
||||
- SigNoz
|
||||
- ZooKeeper
|
||||
|
||||
### 1. Install Foundry
|
||||
> If you used the docker compose file we provided, the volumes will be `signoz-clickhouse`, `signoz-sqlite`, and `signoz-zookeeper-1`.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://signoz.io/foundry.sh | bash
|
||||
```
|
||||
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`.
|
||||
|
||||
### 2. Keep your rollback path
|
||||
Foundry has a [Docker Compose example](https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose). Please use it as a reference.
|
||||
|
||||
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.
|
||||
> [!WARNING]
|
||||
> If your deployment had more than 1 shard or replica, you will need to adjust your manifest volumes accordingly.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 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>
|
||||
> 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.
|
||||
|
||||
```yaml
|
||||
# casting.yaml
|
||||
apiVersion: v1alpha1
|
||||
kind: Installation
|
||||
metadata:
|
||||
@@ -89,8 +61,8 @@ spec:
|
||||
data:
|
||||
config-0-0.yaml: |
|
||||
macros:
|
||||
replica: "example01-01-1" # replace with your replica macro
|
||||
shard: "01" # replace with your shard macro
|
||||
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)
|
||||
patches:
|
||||
- target: "deployment/compose.yaml"
|
||||
operations:
|
||||
@@ -108,165 +80,50 @@ spec:
|
||||
value: root
|
||||
```
|
||||
|
||||
</details>
|
||||
> [!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>
|
||||
<summary><b>Docker Swarm</b> casting.yaml</summary>
|
||||
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.
|
||||
|
||||
```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
|
||||
```
|
||||
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.
|
||||
|
||||
</details>
|
||||
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.
|
||||
|
||||
> [!NOTE]
|
||||
> 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.
|
||||
> This will generate downtime so please plan accordingly.
|
||||
|
||||
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].
|
||||
5. Validate the SigNoz containers are down with `docker ps -a`. Multiple containers cannot bind the same volume.
|
||||
|
||||
### 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
|
||||
```
|
||||
6. Run `foundryctl cast -f casting.yaml`. This will recreate the containers based on the spec. This process will download new container images.
|
||||
|
||||
> [!NOTE]
|
||||
> This causes downtime, so plan accordingly.
|
||||
> When `cast` is run, the migration container will execute its migrations.
|
||||
|
||||
Confirm nothing is still bound to the volumes before continuing:
|
||||
## 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.
|
||||
|
||||
```bash
|
||||
docker ps -a
|
||||
```
|
||||
## 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:
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the migration runs into trouble, see
|
||||
[Troubleshooting Foundry][troubleshooting] for how to capture what we need to
|
||||
help (the `--debug` output, the exit code, and your `casting.yaml`), then reach
|
||||
out on [Slack][slack].
|
||||
- Please reach out to our community on [Slack](https://signoz.io/slack).
|
||||
|
||||
## References
|
||||
|
||||
- [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/
|
||||
[troubleshooting]: https://signoz.io/docs/setup/foundry/troubleshooting/faq/
|
||||
[slack]: https://signoz.io/slack
|
||||
[signoz-docs]: https://signoz.io/docs
|
||||
- [SigNoz Docker installation docs](https://signoz.io/docs/install/docker/)
|
||||
- [SigNoz documentation](https://signoz.io/docs)
|
||||
- [Foundry](https://github.com/SigNoz/foundry)
|
||||
|
||||
@@ -651,6 +651,8 @@ components:
|
||||
$ref: '#/components/schemas/AuthtypesTransactionGroups'
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
- transactionGroups
|
||||
type: object
|
||||
AuthtypesPostableRotateToken:
|
||||
properties:
|
||||
@@ -2405,46 +2407,6 @@ 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:
|
||||
@@ -2486,7 +2448,7 @@ components:
|
||||
CoretypesResourceRef:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/CoretypesKind'
|
||||
type: string
|
||||
type:
|
||||
$ref: '#/components/schemas/CoretypesType'
|
||||
required:
|
||||
@@ -15700,20 +15662,16 @@ paths:
|
||||
summary: List metric names
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/alerts:
|
||||
/api/v2/metrics/{metric_name}/alerts:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns associated alerts for a specified metric
|
||||
operationId: GetMetricAlerts
|
||||
parameters:
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
- in: path
|
||||
name: metric_name
|
||||
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":
|
||||
@@ -15768,36 +15726,28 @@ paths:
|
||||
summary: Get metric alerts
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/attributes:
|
||||
/api/v2/metrics/{metric_name}/attributes:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns attribute keys and their unique values for
|
||||
a specified metric
|
||||
operationId: GetMetricAttributes
|
||||
parameters:
|
||||
- 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
|
||||
- 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
|
||||
- in: query
|
||||
name: end
|
||||
schema:
|
||||
description: End of the time range as a Unix timestamp in milliseconds.
|
||||
nullable: true
|
||||
type: integer
|
||||
- in: path
|
||||
name: metric_name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -15851,20 +15801,16 @@ paths:
|
||||
summary: Get metric attributes
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/dashboards:
|
||||
/api/v2/metrics/{metric_name}/dashboards:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns associated dashboards for a specified metric
|
||||
operationId: GetMetricDashboards
|
||||
parameters:
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
- in: path
|
||||
name: metric_name
|
||||
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":
|
||||
@@ -15919,21 +15865,17 @@ paths:
|
||||
summary: Get metric dashboards
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/highlights:
|
||||
/api/v2/metrics/{metric_name}/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:
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
- in: path
|
||||
name: metric_name
|
||||
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":
|
||||
@@ -15988,79 +15930,17 @@ paths:
|
||||
summary: Get metric highlights
|
||||
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/metadata:
|
||||
/api/v2/metrics/{metric_name}/metadata:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns metadata information like metric description,
|
||||
unit, type, temporality, monotonicity for a specified metric
|
||||
operationId: GetMetricMetadata
|
||||
parameters:
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
- in: path
|
||||
name: metric_name
|
||||
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":
|
||||
@@ -16120,6 +16000,12 @@ 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:
|
||||
@@ -16160,6 +16046,64 @@ 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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"title": "WebSettings",
|
||||
"required": [
|
||||
"posthog",
|
||||
"appcues",
|
||||
@@ -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 ./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 */'"
|
||||
"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 */'"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
|
||||
@@ -19,15 +19,16 @@ import type {
|
||||
|
||||
import type {
|
||||
GetMetricAlerts200,
|
||||
GetMetricAlertsParams,
|
||||
GetMetricAlertsPathParameters,
|
||||
GetMetricAttributes200,
|
||||
GetMetricAttributesParams,
|
||||
GetMetricAttributesPathParameters,
|
||||
GetMetricDashboards200,
|
||||
GetMetricDashboardsParams,
|
||||
GetMetricDashboardsPathParameters,
|
||||
GetMetricHighlights200,
|
||||
GetMetricHighlightsParams,
|
||||
GetMetricHighlightsPathParameters,
|
||||
GetMetricMetadata200,
|
||||
GetMetricMetadataParams,
|
||||
GetMetricMetadataPathParameters,
|
||||
GetMetricsOnboardingStatus200,
|
||||
GetMetricsStats200,
|
||||
GetMetricsTreemap200,
|
||||
@@ -39,6 +40,7 @@ import type {
|
||||
MetricsexplorertypesTreemapRequestDTO,
|
||||
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateMetricMetadataPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -144,26 +146,27 @@ export const invalidateListMetrics = async (
|
||||
* @summary Get metric alerts
|
||||
*/
|
||||
export const getMetricAlerts = (
|
||||
params: GetMetricAlertsParams,
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricAlerts200>({
|
||||
url: `/api/v2/metrics/alerts`,
|
||||
url: `/api/v2/metrics/${metricName}/alerts`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricAlertsQueryKey = (params?: GetMetricAlertsParams) => {
|
||||
return [`/api/v2/metrics/alerts`, ...(params ? [params] : [])] as const;
|
||||
export const getGetMetricAlertsQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricAlertsPathParameters) => {
|
||||
return [`/api/v2/metrics/${metricName}/alerts`] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricAlertsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricAlertsParams,
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
@@ -174,13 +177,19 @@ export const getGetMetricAlertsQueryOptions = <
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetMetricAlertsQueryKey(params);
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricAlertsQueryKey({ metricName });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMetricAlerts>>> = ({
|
||||
signal,
|
||||
}) => getMetricAlerts(params, signal);
|
||||
}) => getMetricAlerts({ metricName }, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -200,7 +209,7 @@ export function useGetMetricAlerts<
|
||||
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricAlertsParams,
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
@@ -209,7 +218,7 @@ export function useGetMetricAlerts<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricAlertsQueryOptions(params, options);
|
||||
const queryOptions = getGetMetricAlertsQueryOptions({ metricName }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -223,11 +232,11 @@ export function useGetMetricAlerts<
|
||||
*/
|
||||
export const invalidateGetMetricAlerts = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetMetricAlertsParams,
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricAlertsQueryKey(params) },
|
||||
{ queryKey: getGetMetricAlertsQueryKey({ metricName }) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -239,11 +248,12 @@ export const invalidateGetMetricAlerts = async (
|
||||
* @summary Get metric attributes
|
||||
*/
|
||||
export const getMetricAttributes = (
|
||||
params: GetMetricAttributesParams,
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricAttributes200>({
|
||||
url: `/api/v2/metrics/attributes`,
|
||||
url: `/api/v2/metrics/${metricName}/attributes`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
@@ -251,16 +261,21 @@ export const getMetricAttributes = (
|
||||
};
|
||||
|
||||
export const getGetMetricAttributesQueryKey = (
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
) => {
|
||||
return [`/api/v2/metrics/attributes`, ...(params ? [params] : [])] as const;
|
||||
return [
|
||||
`/api/v2/metrics/${metricName}/attributes`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricAttributesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricAttributesParams,
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
@@ -272,13 +287,19 @@ export const getGetMetricAttributesQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricAttributesQueryKey(params);
|
||||
queryOptions?.queryKey ??
|
||||
getGetMetricAttributesQueryKey({ metricName }, params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricAttributes>>
|
||||
> = ({ signal }) => getMetricAttributes(params, signal);
|
||||
> = ({ signal }) => getMetricAttributes({ metricName }, params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -298,7 +319,8 @@ export function useGetMetricAttributes<
|
||||
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricAttributesParams,
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
@@ -307,7 +329,11 @@ export function useGetMetricAttributes<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricAttributesQueryOptions(params, options);
|
||||
const queryOptions = getGetMetricAttributesQueryOptions(
|
||||
{ metricName },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -321,11 +347,12 @@ export function useGetMetricAttributes<
|
||||
*/
|
||||
export const invalidateGetMetricAttributes = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetMetricAttributesParams,
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricAttributesQueryKey(params) },
|
||||
{ queryKey: getGetMetricAttributesQueryKey({ metricName }, params) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -337,28 +364,27 @@ export const invalidateGetMetricAttributes = async (
|
||||
* @summary Get metric dashboards
|
||||
*/
|
||||
export const getMetricDashboards = (
|
||||
params: GetMetricDashboardsParams,
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricDashboards200>({
|
||||
url: `/api/v2/metrics/dashboards`,
|
||||
url: `/api/v2/metrics/${metricName}/dashboards`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsQueryKey = (
|
||||
params?: GetMetricDashboardsParams,
|
||||
) => {
|
||||
return [`/api/v2/metrics/dashboards`, ...(params ? [params] : [])] as const;
|
||||
export const getGetMetricDashboardsQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricDashboardsPathParameters) => {
|
||||
return [`/api/v2/metrics/${metricName}/dashboards`] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricDashboardsParams,
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
@@ -370,13 +396,18 @@ export const getGetMetricDashboardsQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey(params);
|
||||
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey({ metricName });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>
|
||||
> = ({ signal }) => getMetricDashboards(params, signal);
|
||||
> = ({ signal }) => getMetricDashboards({ metricName }, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -396,7 +427,7 @@ export function useGetMetricDashboards<
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricDashboardsParams,
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
@@ -405,7 +436,10 @@ export function useGetMetricDashboards<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricDashboardsQueryOptions(params, options);
|
||||
const queryOptions = getGetMetricDashboardsQueryOptions(
|
||||
{ metricName },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -419,11 +453,11 @@ export function useGetMetricDashboards<
|
||||
*/
|
||||
export const invalidateGetMetricDashboards = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetMetricDashboardsParams,
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricDashboardsQueryKey(params) },
|
||||
{ queryKey: getGetMetricDashboardsQueryKey({ metricName }) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -435,28 +469,27 @@ export const invalidateGetMetricDashboards = async (
|
||||
* @summary Get metric highlights
|
||||
*/
|
||||
export const getMetricHighlights = (
|
||||
params: GetMetricHighlightsParams,
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricHighlights200>({
|
||||
url: `/api/v2/metrics/highlights`,
|
||||
url: `/api/v2/metrics/${metricName}/highlights`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricHighlightsQueryKey = (
|
||||
params?: GetMetricHighlightsParams,
|
||||
) => {
|
||||
return [`/api/v2/metrics/highlights`, ...(params ? [params] : [])] as const;
|
||||
export const getGetMetricHighlightsQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricHighlightsPathParameters) => {
|
||||
return [`/api/v2/metrics/${metricName}/highlights`] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricHighlightsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricHighlightsParams,
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
@@ -468,13 +501,18 @@ export const getGetMetricHighlightsQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey(params);
|
||||
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey({ metricName });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>
|
||||
> = ({ signal }) => getMetricHighlights(params, signal);
|
||||
> = ({ signal }) => getMetricHighlights({ metricName }, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -494,7 +532,7 @@ export function useGetMetricHighlights<
|
||||
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricHighlightsParams,
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
@@ -503,7 +541,10 @@ export function useGetMetricHighlights<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricHighlightsQueryOptions(params, options);
|
||||
const queryOptions = getGetMetricHighlightsQueryOptions(
|
||||
{ metricName },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -517,17 +558,219 @@ export function useGetMetricHighlights<
|
||||
*/
|
||||
export const invalidateGetMetricHighlights = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetMetricHighlightsParams,
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricHighlightsQueryKey(params) },
|
||||
{ queryKey: getGetMetricHighlightsQueryKey({ metricName }) },
|
||||
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
|
||||
@@ -611,188 +854,6 @@ 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,45 +2094,6 @@ 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',
|
||||
@@ -2143,7 +2104,10 @@ export enum CoretypesTypeDTO {
|
||||
telemetryresource = 'telemetryresource',
|
||||
}
|
||||
export interface CoretypesResourceRefDTO {
|
||||
kind: CoretypesKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind: string;
|
||||
type: CoretypesTypeDTO;
|
||||
}
|
||||
|
||||
@@ -2279,12 +2243,12 @@ export interface AuthtypesPostableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
transactionGroups?: AuthtypesTransactionGroupsDTO;
|
||||
transactionGroups: AuthtypesTransactionGroupsDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableRotateTokenDTO {
|
||||
@@ -10370,14 +10334,9 @@ export type ListMetrics200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
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).
|
||||
*/
|
||||
export type GetMetricAlertsPathParameters = {
|
||||
metricName: string;
|
||||
};
|
||||
|
||||
export type GetMetricAlerts200 = {
|
||||
data: MetricsexplorertypesMetricAlertsResponseDTO;
|
||||
/**
|
||||
@@ -10386,20 +10345,18 @@ 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 Start of the time range as a Unix timestamp in milliseconds.
|
||||
* @description undefined
|
||||
*/
|
||||
start?: number | null;
|
||||
/**
|
||||
* @type integer,null
|
||||
* @description End of the time range as a Unix timestamp in milliseconds.
|
||||
* @description undefined
|
||||
*/
|
||||
end?: number | null;
|
||||
};
|
||||
@@ -10412,14 +10369,9 @@ export type GetMetricAttributes200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
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).
|
||||
*/
|
||||
export type GetMetricDashboardsPathParameters = {
|
||||
metricName: string;
|
||||
};
|
||||
|
||||
export type GetMetricDashboards200 = {
|
||||
data: MetricsexplorertypesMetricDashboardsResponseDTO;
|
||||
/**
|
||||
@@ -10428,14 +10380,9 @@ export type GetMetricDashboards200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
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).
|
||||
*/
|
||||
export type GetMetricHighlightsPathParameters = {
|
||||
metricName: string;
|
||||
};
|
||||
|
||||
export type GetMetricHighlights200 = {
|
||||
data: MetricsexplorertypesMetricHighlightsResponseDTO;
|
||||
/**
|
||||
@@ -10444,24 +10391,22 @@ export type GetMetricHighlights200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type InspectMetrics200 = {
|
||||
data: MetricsexplorertypesInspectMetricsResponseDTO;
|
||||
export type GetMetricMetadataPathParameters = {
|
||||
metricName: string;
|
||||
};
|
||||
export type GetMetricMetadata200 = {
|
||||
data: MetricsexplorertypesMetricMetadataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
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).
|
||||
*/
|
||||
export type UpdateMetricMetadataPathParameters = {
|
||||
metricName: string;
|
||||
};
|
||||
|
||||
export type GetMetricMetadata200 = {
|
||||
data: MetricsexplorertypesMetricMetadataDTO;
|
||||
export type InspectMetrics200 = {
|
||||
data: MetricsexplorertypesInspectMetricsResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -191,6 +191,9 @@ function TimeSeries({
|
||||
if (metrics[0] && yAxisUnit) {
|
||||
updateMetricMetadata(
|
||||
{
|
||||
pathParams: {
|
||||
metricName: metricNames[0],
|
||||
},
|
||||
data: buildUpdateMetricYAxisUnitPayload(
|
||||
metricNames[0],
|
||||
metrics[0],
|
||||
|
||||
@@ -48,14 +48,18 @@ 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,6 +237,9 @@ function Metadata({
|
||||
const handleSave = useCallback(() => {
|
||||
updateMetricMetadata(
|
||||
{
|
||||
pathParams: {
|
||||
metricName,
|
||||
},
|
||||
data: transformUpdateMetricMetadataRequest(metricName, metricMetadataState),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,7 +56,7 @@ function MetricDetails({
|
||||
);
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
if (!metricMetadataResponse) {
|
||||
if (!metricMetadataResponse?.data) {
|
||||
return null;
|
||||
}
|
||||
const { type, description, unit, temporality, isMonotonic } =
|
||||
|
||||
@@ -195,12 +195,14 @@ 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),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
CoretypesKindDTO,
|
||||
CoretypesObjectGroupDTO,
|
||||
CoretypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -57,7 +56,7 @@ const baseAuthzResources: AuthzResources = {
|
||||
|
||||
// API payload resource refs — only kind+type, no allowedVerbs (matches CoretypesResourceRefDTO shape)
|
||||
const dashboardResourceRef = {
|
||||
kind: 'dashboard' as CoretypesKindDTO,
|
||||
kind: 'dashboard',
|
||||
type: 'metaresource' as CoretypesTypeDTO,
|
||||
};
|
||||
const alertResourceRef = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import type {
|
||||
CoretypesKindDTO,
|
||||
CoretypesObjectGroupDTO,
|
||||
CoretypesResourceRefDTO,
|
||||
CoretypesTypeDTO,
|
||||
@@ -148,7 +147,7 @@ export function buildPatchPayload({
|
||||
continue;
|
||||
}
|
||||
const resourceDef: CoretypesResourceRefDTO = {
|
||||
kind: found.kind as CoretypesKindDTO,
|
||||
kind: found.kind,
|
||||
type: found.type as CoretypesTypeDTO,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ import {
|
||||
AuthtypesTransactionDTO,
|
||||
CoretypesTypeDTO,
|
||||
AuthtypesRelationDTO,
|
||||
CoretypesKindDTO,
|
||||
} from '../../api/generated/services/sigNoz.schemas';
|
||||
import permissionsType from './permissions.type';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
ResourceName,
|
||||
ResourcesForRelation,
|
||||
ResourceType,
|
||||
} from './types';
|
||||
@@ -87,7 +87,7 @@ export function permissionToTransactionDto(
|
||||
relation: relation as AuthtypesRelationDTO,
|
||||
object: {
|
||||
resource: {
|
||||
kind: resourceName as CoretypesKindDTO,
|
||||
kind: resourceName as ResourceName,
|
||||
type: type as CoretypesTypeDTO,
|
||||
},
|
||||
selector: selector || '*',
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface UseInlineOverflowCountOptions {
|
||||
itemCount: number;
|
||||
/** Horizontal gap between items, in px. */
|
||||
gap?: number;
|
||||
/** Width kept free at the end of the line for a trailing "+N" trigger, in px. */
|
||||
reserveWidth?: number;
|
||||
/** Pause measuring (e.g. while expanded) without unmounting. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseInlineOverflowCountResult {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
visibleCount: number;
|
||||
overflowCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures how many of a container's children (each marked
|
||||
* `data-overflow-item="true"`) fit on a single line, reserving `reserveWidth`
|
||||
* for a trailing "+N" trigger. Item widths are cached, so children hidden with
|
||||
* `display: none` still count toward the fit; measuring pauses while `enabled`
|
||||
* is false.
|
||||
*/
|
||||
export function useInlineOverflowCount({
|
||||
itemCount,
|
||||
gap = 8,
|
||||
reserveWidth = 0,
|
||||
enabled = true,
|
||||
}: UseInlineOverflowCountOptions): UseInlineOverflowCountResult {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(itemCount);
|
||||
const itemWidthsRef = useRef<number[]>([]);
|
||||
const enabledRef = useRef(enabled);
|
||||
enabledRef.current = enabled;
|
||||
|
||||
useEffect(() => {
|
||||
itemWidthsRef.current = [];
|
||||
setVisibleCount(itemCount);
|
||||
}, [itemCount]);
|
||||
|
||||
const measure = useCallback((): void => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !enabledRef.current) {
|
||||
return;
|
||||
}
|
||||
const itemElements = Array.from(container.children).filter(
|
||||
(itemElement): itemElement is HTMLElement =>
|
||||
itemElement instanceof HTMLElement &&
|
||||
itemElement.dataset.overflowItem === 'true',
|
||||
);
|
||||
if (itemElements.length === 0) {
|
||||
setVisibleCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
itemElements.forEach((itemElement, index) => {
|
||||
if (itemElement.offsetWidth > 0) {
|
||||
itemWidthsRef.current[index] = itemElement.offsetWidth;
|
||||
}
|
||||
});
|
||||
const cachedWidths: number[] = [];
|
||||
for (let index = 0; index < itemElements.length; index += 1) {
|
||||
const cachedWidth = itemWidthsRef.current[index];
|
||||
if (cachedWidth == null) {
|
||||
// Width not cached yet — reveal everything for one frame so it gets
|
||||
// measured, then the next pass collapses accurately.
|
||||
setVisibleCount(itemElements.length);
|
||||
return;
|
||||
}
|
||||
cachedWidths.push(cachedWidth);
|
||||
}
|
||||
|
||||
const containerWidth = container.clientWidth;
|
||||
const totalWidth = cachedWidths.reduce(
|
||||
(runningTotal, itemWidth, index) =>
|
||||
runningTotal + itemWidth + (index > 0 ? gap : 0),
|
||||
0,
|
||||
);
|
||||
if (totalWidth <= containerWidth) {
|
||||
setVisibleCount(itemElements.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const availableWidth = containerWidth - reserveWidth;
|
||||
let usedWidth = 0;
|
||||
let fitCount = 0;
|
||||
for (let index = 0; index < cachedWidths.length; index += 1) {
|
||||
const itemWidthWithGap = cachedWidths[index] + (index > 0 ? gap : 0);
|
||||
if (usedWidth + itemWidthWithGap > availableWidth && fitCount > 0) {
|
||||
break;
|
||||
}
|
||||
usedWidth += itemWidthWithGap;
|
||||
fitCount += 1;
|
||||
}
|
||||
setVisibleCount(Math.max(1, fitCount));
|
||||
}, [gap, reserveWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return undefined;
|
||||
}
|
||||
const observer = new ResizeObserver(() => measure());
|
||||
observer.observe(container);
|
||||
Array.from(container.children).forEach((child) => observer.observe(child));
|
||||
measure();
|
||||
return (): void => observer.disconnect();
|
||||
}, [measure, itemCount, enabled]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
visibleCount,
|
||||
overflowCount: Math.max(0, itemCount - visibleCount),
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
|
||||
@@ -36,17 +37,16 @@ export default function Legend({
|
||||
|
||||
// Search is intrinsic to the right-positioned legend.
|
||||
const searchEnabled = position === LegendPosition.RIGHT;
|
||||
const { width: containerWidth } = useResizeObserver(legendContainerRef);
|
||||
|
||||
const isSingleRow = useMemo(() => {
|
||||
if (!legendContainerRef.current || position !== LegendPosition.BOTTOM) {
|
||||
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
|
||||
return false;
|
||||
}
|
||||
const containerWidth = legendContainerRef.current.clientWidth;
|
||||
|
||||
const totalLegendWidth = items.length * (averageLegendWidth + 16);
|
||||
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
|
||||
return totalRows <= 1;
|
||||
}, [averageLegendWidth, items.length, position]);
|
||||
}, [averageLegendWidth, items.length, position, containerWidth]);
|
||||
|
||||
const visibleLegendItems = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
.dashboardActionsContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboardActionsSecondary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,32 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Braces,
|
||||
ClipboardCopy,
|
||||
Configure,
|
||||
Copy,
|
||||
Ellipsis,
|
||||
FileJson,
|
||||
Fullscreen,
|
||||
Grid3X3,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
Plus,
|
||||
SquareStack,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
|
||||
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
|
||||
import SettingsDrawer from '../SettingsDrawer';
|
||||
import styles from './DashboardActions.module.scss';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
@@ -65,31 +55,14 @@ function DashboardActions({
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const { user } = useAppContext();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState<boolean>(false);
|
||||
const [isCloning, setIsCloning] = useState<boolean>(false);
|
||||
const [isNewSectionOpen, setIsNewSectionOpen] = useState<boolean>(false);
|
||||
|
||||
const [state, setCopy] = useCopyToClipboard();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
|
||||
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
|
||||
|
||||
const { addSection, isSaving: isAddingSection } = useAddSection({
|
||||
layouts: dashboard.spec.layouts,
|
||||
});
|
||||
|
||||
const handleCreateSection = useCallback(
|
||||
async (title: string): Promise<void> => {
|
||||
await addSection(title);
|
||||
setIsNewSectionOpen(false);
|
||||
},
|
||||
[addSection],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
toast.error(t('something_went_wrong', { ns: 'common' }));
|
||||
@@ -116,24 +89,6 @@ function DashboardActions({
|
||||
URL.revokeObjectURL(url);
|
||||
}, [dashboardDataJSON, title]);
|
||||
|
||||
const handleClone = useCallback(async (): Promise<void> => {
|
||||
if (!dashboard.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsCloning(true);
|
||||
const response = await cloneDashboardV2({ id: dashboard.id });
|
||||
toast.success('Dashboard cloned');
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsCloning(false);
|
||||
}
|
||||
}, [dashboard.id, safeNavigate, showErrorModal]);
|
||||
|
||||
const handleConfirmDelete = useCallback((): void => {
|
||||
deleteDashboardMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
@@ -144,24 +99,17 @@ function DashboardActions({
|
||||
}, [deleteDashboardMutation]);
|
||||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const dashboardGroup: MenuItem[] = [];
|
||||
const editGroup: MenuItem[] = [];
|
||||
if (canEdit) {
|
||||
dashboardGroup.push({
|
||||
editGroup.push({
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
icon: <PenLine size={14} />,
|
||||
onClick: onOpenRename,
|
||||
});
|
||||
}
|
||||
dashboardGroup.push({
|
||||
key: 'clone',
|
||||
label: 'Clone dashboard',
|
||||
icon: <Copy size={14} />,
|
||||
disabled: isCloning,
|
||||
onClick: (): void => void handleClone(),
|
||||
});
|
||||
if (isAuthor || user.role === USER_ROLES.ADMIN) {
|
||||
dashboardGroup.push({
|
||||
editGroup.push({
|
||||
key: 'lock',
|
||||
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
|
||||
icon: <LockKeyhole size={14} />,
|
||||
@@ -169,14 +117,14 @@ function DashboardActions({
|
||||
onClick: onLockToggle,
|
||||
});
|
||||
}
|
||||
dashboardGroup.push({
|
||||
editGroup.push({
|
||||
key: 'fullscreen',
|
||||
label: 'Full screen',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: handle.enter,
|
||||
});
|
||||
|
||||
const dataGroup: MenuItem[] = [
|
||||
const exportGroup: MenuItem[] = [
|
||||
{
|
||||
key: 'export',
|
||||
label: 'Export JSON',
|
||||
@@ -191,35 +139,7 @@ function DashboardActions({
|
||||
},
|
||||
];
|
||||
|
||||
const layoutGroup: MenuItem[] = [];
|
||||
if (canEdit) {
|
||||
layoutGroup.push({
|
||||
key: 'new-section',
|
||||
label: 'New section',
|
||||
icon: <SquareStack size={14} />,
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
});
|
||||
}
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
type: 'group',
|
||||
key: 'group-dashboard',
|
||||
label: 'Dashboard',
|
||||
children: dashboardGroup,
|
||||
},
|
||||
{ type: 'group', key: 'group-data', label: 'Data', children: dataGroup },
|
||||
];
|
||||
if (layoutGroup.length > 0) {
|
||||
items.push({
|
||||
type: 'group',
|
||||
key: 'group-layout',
|
||||
label: 'Layout',
|
||||
children: layoutGroup,
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
{ type: 'divider', key: 'divider-danger' },
|
||||
const dangerGroup: MenuItem[] = [
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete dashboard',
|
||||
@@ -227,85 +147,74 @@ function DashboardActions({
|
||||
danger: true,
|
||||
onClick: (): void => setIsDeleteOpen(true),
|
||||
},
|
||||
);
|
||||
return items;
|
||||
];
|
||||
|
||||
return [editGroup, exportGroup, dangerGroup]
|
||||
.filter((group) => group.length > 0)
|
||||
.flatMap((group, index) =>
|
||||
index > 0 ? [{ type: 'divider' } as MenuItem, ...group] : group,
|
||||
);
|
||||
}, [
|
||||
canEdit,
|
||||
isCloning,
|
||||
isDashboardLocked,
|
||||
isAuthor,
|
||||
user.role,
|
||||
isDashboardLocked,
|
||||
dashboard.createdBy,
|
||||
onOpenRename,
|
||||
handleClone,
|
||||
onLockToggle,
|
||||
handle.enter,
|
||||
exportJSON,
|
||||
setCopy,
|
||||
dashboardDataJSON,
|
||||
canEdit,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardActionsContainer}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Grid3X3 size="md" />}
|
||||
testId="options"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
{canEdit && (
|
||||
<>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
<div className={styles.dashboardActionsSecondary}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="icon"
|
||||
prefix={<Ellipsis size="md" />}
|
||||
testId="options"
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
{!isDashboardLocked && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
New Panel
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={(): void => setIsJsonEditorOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
Edit as JSON
|
||||
</Button>
|
||||
{!isDashboardLocked && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
<JsonEditorDrawer
|
||||
dashboard={dashboard}
|
||||
isOpen={isJsonEditorOpen}
|
||||
onClose={(): void => setIsJsonEditorOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteOpen}
|
||||
title={`Delete dashboard"?`}
|
||||
@@ -314,15 +223,6 @@ function DashboardActions({
|
||||
onConfirm={handleConfirmDelete}
|
||||
onClose={(): void => setIsDeleteOpen(false)}
|
||||
/>
|
||||
<SectionTitleModal
|
||||
open={isNewSectionOpen}
|
||||
heading="New section"
|
||||
okText="Create section"
|
||||
initialValue=""
|
||||
isSaving={isAddingSection}
|
||||
onClose={(): void => setIsNewSectionOpen(false)}
|
||||
onSubmit={handleCreateSection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
.dashboardInfo {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 40%;
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardTitleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboardImage {
|
||||
@@ -11,8 +21,9 @@
|
||||
}
|
||||
|
||||
.dashboardTitle {
|
||||
flex: 0 1 auto;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: fit-content;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
@@ -26,19 +37,6 @@
|
||||
cursor: text !important;
|
||||
}
|
||||
|
||||
.descriptionIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--l2-border);
|
||||
}
|
||||
|
||||
.dashboardTitleEditor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -56,13 +54,8 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Flexes into the remaining space and clips so the ResizeObserver can measure
|
||||
how many tags fit before collapsing the rest into a `+N` badge. */
|
||||
.dashboardTags {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { Check, Globe, LockKeyhole, SolidInfoCircle, X } from '@signozhq/icons';
|
||||
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
@@ -9,7 +9,6 @@ import cx from 'classnames';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { useVisibleTagCount } from './useVisibleTagCount';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface DashboardInfoProps {
|
||||
@@ -46,11 +45,6 @@ function DashboardInfo({
|
||||
const hasTags = tags.length > 0;
|
||||
const hasDescription = !isEmpty(description);
|
||||
|
||||
const { containerRef, visibleCount } = useVisibleTagCount(tags);
|
||||
const needsOverflow = tags.length > visibleCount;
|
||||
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
|
||||
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -62,106 +56,83 @@ function DashboardInfo({
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardInfo}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
<div className={styles.dashboardTitleContainer}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={120}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="icon"
|
||||
className={styles.dashboardTitleActionButton}
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.dashboardTitleActionButton}
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={120}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="icon"
|
||||
className={styles.dashboardTitleActionButton}
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.dashboardTitleActionButton}
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
{isPublicDashboard && (
|
||||
<TooltipSimple title="This dashboard is publicly accessible">
|
||||
<Globe size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<TooltipSimple title="This dashboard is locked">
|
||||
<LockKeyhole size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasTags && (
|
||||
<div className={styles.dashboardTags}>
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} color="warning" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{hasDescription && (
|
||||
<TooltipSimple title={description}>
|
||||
<SolidInfoCircle
|
||||
className={styles.descriptionIcon}
|
||||
size={14}
|
||||
data-testid="dashboard-description-info"
|
||||
/>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
<TooltipSimple title="This dashboard is publicly accessible">
|
||||
<Globe size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<TooltipSimple title="This dashboard is locked">
|
||||
<LockKeyhole size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{hasTags && (
|
||||
<>
|
||||
<span className={styles.divider} />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.dashboardTags}
|
||||
data-testid="dashboard-tags"
|
||||
>
|
||||
{visibleTags.map((tag) => (
|
||||
<Badge key={tag} color="warning" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{remainingTags.length > 0 && (
|
||||
<TooltipSimple title={remainingTags.join(', ')}>
|
||||
<Badge
|
||||
color="warning"
|
||||
variant="outline"
|
||||
data-testid="dashboard-tags-overflow"
|
||||
>
|
||||
+{remainingTags.length}
|
||||
</Badge>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<Typography.Text color="muted">{description}</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BADGE_GAP,
|
||||
estimateBadgeWidth,
|
||||
OVERFLOW_BADGE_WIDTH,
|
||||
} from 'components/Alerts/LabelColumn/utils';
|
||||
|
||||
interface Result {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
visibleCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures how many tags fit in the container and returns the visible count,
|
||||
* reserving room for the `+N` overflow badge. Reuses the badge-width estimation
|
||||
* from the alerts LabelColumn so dashboards and alerts overflow identically.
|
||||
*/
|
||||
export function useVisibleTagCount(tags: string[]): Result {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(tags.length);
|
||||
|
||||
const calculateVisible = useCallback(
|
||||
(width: number): number => {
|
||||
if (width <= 0) {
|
||||
return 1;
|
||||
}
|
||||
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
|
||||
let usedWidth = 0;
|
||||
let count = 0;
|
||||
for (const tag of tags) {
|
||||
const badgeWidth = estimateBadgeWidth(tag) + BADGE_GAP;
|
||||
if (usedWidth + badgeWidth > availableWidth && count > 0) {
|
||||
break;
|
||||
}
|
||||
usedWidth += badgeWidth;
|
||||
count += 1;
|
||||
}
|
||||
return Math.max(1, count);
|
||||
},
|
||||
[tags],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return undefined;
|
||||
}
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry && entry.contentRect.width > 0) {
|
||||
setVisibleCount(calculateVisible(entry.contentRect.width));
|
||||
}
|
||||
});
|
||||
observer.observe(container);
|
||||
if (container.clientWidth > 0) {
|
||||
setVisibleCount(calculateVisible(container.clientWidth));
|
||||
}
|
||||
return (): void => observer.disconnect();
|
||||
}, [calculateVisible]);
|
||||
|
||||
return { containerRef, visibleCount };
|
||||
}
|
||||
@@ -5,9 +5,7 @@
|
||||
color: var(--l2-foreground);
|
||||
background-color: var(--l1-background);
|
||||
padding: 16px;
|
||||
box-shadow:
|
||||
0 1px 0 0 var(--l2-border),
|
||||
0 6px 12px -10px var(--l2-border);
|
||||
box-shadow: 0 2px 2px 0px var(--l2-border);
|
||||
}
|
||||
|
||||
.dashboardPageToolbarSubContainer {
|
||||
@@ -18,22 +16,5 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbarRow2 {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
|
||||
&::after {
|
||||
display: block;
|
||||
clear: both;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
.timeCluster {
|
||||
float: right;
|
||||
margin: 0 0 0 16px;
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
.root {
|
||||
:global(.ant-drawer-wrapper-body) {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
:global(.ant-drawer-header) {
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
:global(.ant-drawer-title) {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-drawer-body) {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global(.ant-drawer-footer) {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.validation {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.validationValid {
|
||||
color: var(--bg-forest-400);
|
||||
}
|
||||
|
||||
.validationInvalid {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { KeyboardEvent, useCallback } from 'react';
|
||||
import MEditor from '@monaco-editor/react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { Drawer } from 'antd';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
|
||||
import styles from './JsonEditorDrawer.module.scss';
|
||||
import JsonEditorToolbar from './JsonEditorToolbar';
|
||||
import { useJsonEditor } from './useJsonEditor';
|
||||
|
||||
interface JsonEditorDrawerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function JsonEditorDrawer({
|
||||
dashboard,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: JsonEditorDrawerProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
|
||||
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
|
||||
|
||||
const onCopy = useCallback((): void => {
|
||||
copyToClipboard(draft);
|
||||
toast.success('JSON copied to clipboard');
|
||||
}, [copyToClipboard, draft]);
|
||||
|
||||
const onDownload = useCallback((): void => {
|
||||
const blob = new Blob([draft], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${dashboard.name || 'dashboard'}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [draft, dashboard.name]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void apply();
|
||||
}
|
||||
},
|
||||
[apply],
|
||||
);
|
||||
|
||||
const applyDisabled = !isDirty || !validity.valid || isSaving;
|
||||
const validationText = validity.valid
|
||||
? `Valid JSON · ${validity.lineCount} lines`
|
||||
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Dashboard JSON"
|
||||
placement="right"
|
||||
width={660}
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
rootClassName={styles.root}
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Typography.Text
|
||||
className={cx(styles.validation, {
|
||||
[styles.validationValid]: validity.valid,
|
||||
[styles.validationInvalid]: !validity.valid,
|
||||
})}
|
||||
data-testid="json-editor-validation"
|
||||
>
|
||||
{validationText}
|
||||
</Typography.Text>
|
||||
<div className={styles.footerActions}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
testId="json-editor-cancel"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="json-editor-apply"
|
||||
disabled={applyDisabled}
|
||||
onClick={(): void => void apply()}
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div className={styles.body} onKeyDown={onKeyDown}>
|
||||
<JsonEditorToolbar
|
||||
isDirty={isDirty}
|
||||
onFormat={format}
|
||||
onCopy={onCopy}
|
||||
onDownload={onDownload}
|
||||
onReset={reset}
|
||||
/>
|
||||
<div className={styles.editor}>
|
||||
<MEditor
|
||||
language="json"
|
||||
height="100%"
|
||||
value={draft}
|
||||
onChange={(value): void => setDraft(value ?? '')}
|
||||
options={{
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
fontFamily: 'Space Mono',
|
||||
}}
|
||||
theme="vs-dark"
|
||||
onMount={(editor, monaco): void => {
|
||||
defineJsonEditorTheme(monaco, editor.getContainerDomNode());
|
||||
monaco.editor.setTheme(JSON_EDITOR_THEME);
|
||||
void document.fonts.ready.then(() => monaco.editor.remeasureFonts());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonEditorDrawer;
|
||||
@@ -1,12 +0,0 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { AlignLeft, Copy, Download, RotateCcw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './JsonEditorToolbar.module.scss';
|
||||
|
||||
interface JsonEditorToolbarProps {
|
||||
isDirty: boolean;
|
||||
onFormat: () => void;
|
||||
onCopy: () => void;
|
||||
onDownload: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
function JsonEditorToolbar({
|
||||
isDirty,
|
||||
onFormat,
|
||||
onCopy,
|
||||
onDownload,
|
||||
onReset,
|
||||
}: JsonEditorToolbarProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<AlignLeft size={14} />}
|
||||
testId="json-editor-format"
|
||||
onClick={onFormat}
|
||||
>
|
||||
Format
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Copy size={14} />}
|
||||
testId="json-editor-copy"
|
||||
onClick={onCopy}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Download size={14} />}
|
||||
testId="json-editor-download"
|
||||
onClick={onDownload}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
<div className={styles.spacer} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<RotateCcw size={14} />}
|
||||
testId="json-editor-reset"
|
||||
disabled={!isDirty}
|
||||
onClick={onReset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonEditorToolbar;
|
||||
@@ -1,165 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer';
|
||||
import { useJsonEditor } from '../useJsonEditor';
|
||||
|
||||
jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
|
||||
|
||||
jest.mock('@monaco-editor/react', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next?: string) => void;
|
||||
}): JSX.Element => (
|
||||
<textarea
|
||||
aria-label="json-editor"
|
||||
data-testid="monaco"
|
||||
value={value}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [{}, jest.fn()],
|
||||
}));
|
||||
|
||||
const mockUseJsonEditor = useJsonEditor as jest.Mock;
|
||||
|
||||
const dashboard = {
|
||||
id: 'dash-1',
|
||||
name: 'My dashboard',
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
function hookValue(
|
||||
overrides: Partial<ReturnType<typeof useJsonEditor>> = {},
|
||||
): ReturnType<typeof useJsonEditor> {
|
||||
return {
|
||||
draft: '{\n "a": 1\n}',
|
||||
setDraft: jest.fn(),
|
||||
validity: { valid: true, lineCount: 3 },
|
||||
isDirty: true,
|
||||
isSaving: false,
|
||||
format: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
apply: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('JsonEditorDrawer', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the toolbar, editor and footer actions when open', () => {
|
||||
mockUseJsonEditor.mockReturnValue(hookValue());
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('json-editor-format')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('json-editor-copy')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('json-editor-download')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('json-editor-reset')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('json-editor-apply')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('monaco')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a valid status with the line count', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({ validity: { valid: true, lineCount: 12 } }),
|
||||
);
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('json-editor-validation')).toHaveTextContent(
|
||||
'Valid JSON · 12 lines',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the error line and message when invalid', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({
|
||||
validity: {
|
||||
valid: false,
|
||||
lineCount: 4,
|
||||
errorLine: 3,
|
||||
message: 'Unexpected token',
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('json-editor-validation')).toHaveTextContent(
|
||||
'Line 3 · Unexpected token',
|
||||
);
|
||||
});
|
||||
|
||||
it('disables Apply when not dirty, invalid, or saving', () => {
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ isDirty: false }));
|
||||
const { rerender } = render(
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
|
||||
);
|
||||
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
|
||||
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({ validity: { valid: false, lineCount: 1 } }),
|
||||
);
|
||||
rerender(
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
|
||||
);
|
||||
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
|
||||
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ isSaving: true }));
|
||||
rerender(
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
|
||||
);
|
||||
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('wires toolbar and footer buttons to the hook callbacks', () => {
|
||||
const value = hookValue();
|
||||
mockUseJsonEditor.mockReturnValue(value);
|
||||
const onClose = jest.fn();
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={onClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('json-editor-format'));
|
||||
expect(value.format).toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('json-editor-reset'));
|
||||
expect(value.reset).toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('json-editor-apply'));
|
||||
expect(value.apply).toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('json-editor-cancel'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards editor changes to setDraft', () => {
|
||||
const value = hookValue();
|
||||
mockUseJsonEditor.mockReturnValue(value);
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('monaco'), {
|
||||
target: { value: '{"b":2}' },
|
||||
});
|
||||
expect(value.setDraft).toHaveBeenCalledWith('{"b":2}');
|
||||
});
|
||||
|
||||
it('applies on Cmd/Ctrl+Enter', () => {
|
||||
const value = hookValue();
|
||||
mockUseJsonEditor.mockReturnValue(value);
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('monaco'), {
|
||||
key: 'Enter',
|
||||
metaKey: true,
|
||||
});
|
||||
expect(value.apply).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { updateDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { useJsonEditor } from '../useJsonEditor';
|
||||
|
||||
const mockRefetch = jest.fn();
|
||||
const mockShowErrorModal = jest.fn();
|
||||
|
||||
jest.mock('../../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1', refetch: mockRefetch }),
|
||||
}));
|
||||
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
useErrorModal: (): { showErrorModal: jest.Mock } => ({
|
||||
showErrorModal: mockShowErrorModal,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
updateDashboardV2: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockUpdate = updateDashboardV2 as jest.Mock;
|
||||
const mockToastSuccess = toast.success as jest.Mock;
|
||||
|
||||
const dashboard = {
|
||||
id: 'dash-1',
|
||||
name: 'My dashboard',
|
||||
schemaVersion: 'v6',
|
||||
image: 'icon.png',
|
||||
tags: [{ key: 'env', value: 'prod' }],
|
||||
spec: {
|
||||
display: { name: 'My dashboard' },
|
||||
panels: {},
|
||||
layouts: [],
|
||||
variables: [],
|
||||
},
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
const serialized = JSON.stringify(dashboard, null, 2);
|
||||
|
||||
describe('useJsonEditor', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUpdate.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('seeds the draft from the dashboard and reports valid, non-dirty state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
expect(result.current.draft).toBe(serialized);
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
expect(result.current.validity.valid).toBe(true);
|
||||
expect(result.current.validity.lineCount).toBe(serialized.split('\n').length);
|
||||
});
|
||||
|
||||
it('flags invalid JSON with a line number and marks the draft dirty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft('{\n "name": ,\n}'));
|
||||
|
||||
expect(result.current.validity.valid).toBe(false);
|
||||
expect(result.current.validity.message).toBeDefined();
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('format() pretty-prints valid JSON and leaves invalid JSON untouched', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft('{"a":1}'));
|
||||
act(() => result.current.format());
|
||||
expect(result.current.draft).toBe('{\n "a": 1\n}');
|
||||
|
||||
act(() => result.current.setDraft('{bad'));
|
||||
act(() => result.current.format());
|
||||
expect(result.current.draft).toBe('{bad');
|
||||
});
|
||||
|
||||
it('reset() restores the last-applied text', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft('edited'));
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.draft).toBe(serialized);
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('apply() is a no-op when the draft is unchanged or invalid', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.apply();
|
||||
});
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
|
||||
act(() => result.current.setDraft('{bad'));
|
||||
await act(async () => {
|
||||
await result.current.apply();
|
||||
});
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('apply() PUTs the narrowed body, toasts, refetches and calls onApplied', async () => {
|
||||
const onApplied = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied }),
|
||||
);
|
||||
|
||||
const next = { ...dashboard, name: 'Renamed' };
|
||||
act(() => result.current.setDraft(JSON.stringify(next)));
|
||||
await act(async () => {
|
||||
await result.current.apply();
|
||||
});
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
{ id: 'dash-1' },
|
||||
expect.objectContaining({
|
||||
name: 'Renamed',
|
||||
schemaVersion: 'v6',
|
||||
spec: next.spec,
|
||||
tags: next.tags,
|
||||
}),
|
||||
);
|
||||
expect(mockToastSuccess).toHaveBeenCalled();
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
expect(onApplied).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('apply() surfaces errors through the error modal', async () => {
|
||||
mockUpdate.mockRejectedValueOnce(new Error('boom'));
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
act(() =>
|
||||
result.current.setDraft(JSON.stringify({ ...dashboard, name: 'X' })),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.apply();
|
||||
});
|
||||
|
||||
expect(mockShowErrorModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-seeds the draft when the drawer re-opens', () => {
|
||||
const onApplied = jest.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { isOpen: boolean }) =>
|
||||
useJsonEditor({ dashboard, isOpen: props.isOpen, onApplied }),
|
||||
{ initialProps: { isOpen: false } },
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft('stale edit'));
|
||||
expect(result.current.draft).toBe('stale edit');
|
||||
|
||||
rerender({ isOpen: true });
|
||||
expect(result.current.draft).toBe(serialized);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesDashboardSpecDTO,
|
||||
DashboardtypesUpdatableDashboardV2DTO,
|
||||
TagtypesPostableTagDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Narrow a parsed (full Gettable-shaped) dashboard JSON down to the PUT-updatable
|
||||
* body. The editor shows the whole dashboard for readability, but the update
|
||||
* endpoint only accepts `{ name, schemaVersion, image, tags, spec }` — the
|
||||
* server owns `id`, `locked`, timestamps, etc., so we drop them here.
|
||||
*/
|
||||
export function dashboardToUpdatable(
|
||||
parsed: Record<string, unknown>,
|
||||
): DashboardtypesUpdatableDashboardV2DTO {
|
||||
const dashboard = parsed as Partial<DashboardtypesGettableDashboardV2DTO>;
|
||||
|
||||
return {
|
||||
name: dashboard.name ?? '',
|
||||
schemaVersion: dashboard.schemaVersion ?? 'v6',
|
||||
image: dashboard.image,
|
||||
tags: (dashboard.tags as TagtypesPostableTagDTO[] | null | undefined) ?? null,
|
||||
spec: dashboard.spec as DashboardtypesDashboardSpecDTO,
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { Monaco } from '@monaco-editor/react';
|
||||
|
||||
export const JSON_EDITOR_THEME = 'signoz-json';
|
||||
|
||||
function token(el: HTMLElement, name: string): string {
|
||||
return getComputedStyle(el).getPropertyValue(name).trim().replace('#', '');
|
||||
}
|
||||
|
||||
function isDark(hex: string): boolean {
|
||||
if (hex.length < 6) {
|
||||
return true;
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return 0.299 * r + 0.587 * g + 0.114 * b < 128;
|
||||
}
|
||||
|
||||
export function defineJsonEditorTheme(monaco: Monaco, el: HTMLElement): void {
|
||||
const background = token(el, '--l1-background');
|
||||
const foreground = token(el, '--l1-foreground');
|
||||
const keyColor = token(el, '--bg-vanilla-400');
|
||||
const valueColor = token(el, '--bg-robin-400');
|
||||
|
||||
const rules: { token: string; foreground: string }[] = [];
|
||||
if (keyColor) {
|
||||
rules.push({ token: 'string.key.json', foreground: keyColor });
|
||||
}
|
||||
if (valueColor) {
|
||||
rules.push({ token: 'string.value.json', foreground: valueColor });
|
||||
}
|
||||
|
||||
const colors: Record<string, string> = {};
|
||||
if (background) {
|
||||
colors['editor.background'] = `#${background}`;
|
||||
}
|
||||
if (foreground) {
|
||||
colors['editor.foreground'] = `#${foreground}`;
|
||||
}
|
||||
|
||||
monaco.editor.defineTheme(JSON_EDITOR_THEME, {
|
||||
base: isDark(background) ? 'vs-dark' : 'vs',
|
||||
inherit: true,
|
||||
rules,
|
||||
colors,
|
||||
});
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { updateDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { dashboardToUpdatable } from './dashboardToUpdatable';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
export interface JsonValidity {
|
||||
valid: boolean;
|
||||
lineCount: number;
|
||||
/** 1-based line of the parse error, when known. */
|
||||
errorLine?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface Params {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
isOpen: boolean;
|
||||
onApplied: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
draft: string;
|
||||
setDraft: (next: string) => void;
|
||||
validity: JsonValidity;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
format: () => void;
|
||||
reset: () => void;
|
||||
apply: () => Promise<void>;
|
||||
}
|
||||
|
||||
const serialize = (dashboard: DashboardtypesGettableDashboardV2DTO): string =>
|
||||
JSON.stringify(dashboard, null, 2);
|
||||
|
||||
/** Derive a 1-based line number from a `JSON.parse` "position N" error message. */
|
||||
function errorLineFromMessage(
|
||||
source: string,
|
||||
message: string,
|
||||
): number | undefined {
|
||||
const match = /position (\d+)/.exec(message);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const position = Number(match[1]);
|
||||
return source.slice(0, position).split('\n').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor state for the dashboard JSON drawer: tracks the editable `draft`
|
||||
* against the last-applied text, exposes live validation, and applies changes
|
||||
* via the full-document update endpoint.
|
||||
*/
|
||||
export function useJsonEditor({
|
||||
dashboard,
|
||||
isOpen,
|
||||
onApplied,
|
||||
}: Params): Result {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const [appliedText, setAppliedText] = useState<string>(() =>
|
||||
serialize(dashboard),
|
||||
);
|
||||
const [draft, setDraft] = useState<string>(appliedText);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Re-seed the editor from the current dashboard each time the drawer opens so
|
||||
// it always reflects the latest persisted state (e.g. after a refetch).
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const next = serialize(dashboard);
|
||||
setAppliedText(next);
|
||||
setDraft(next);
|
||||
}
|
||||
}, [isOpen, dashboard]);
|
||||
|
||||
const validity = useMemo<JsonValidity>(() => {
|
||||
const lineCount = draft.split('\n').length;
|
||||
try {
|
||||
JSON.parse(draft);
|
||||
return { valid: true, lineCount };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid JSON';
|
||||
return {
|
||||
valid: false,
|
||||
lineCount,
|
||||
errorLine: errorLineFromMessage(draft, message),
|
||||
message,
|
||||
};
|
||||
}
|
||||
}, [draft]);
|
||||
|
||||
const isDirty = draft !== appliedText;
|
||||
|
||||
const format = useCallback((): void => {
|
||||
try {
|
||||
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
|
||||
} catch {
|
||||
// Leave the draft untouched when it can't be parsed.
|
||||
}
|
||||
}, [draft]);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setDraft(appliedText);
|
||||
}, [appliedText]);
|
||||
|
||||
const apply = useCallback(async (): Promise<void> => {
|
||||
if (!validity.valid || !isDirty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const parsed = JSON.parse(draft) as Record<string, unknown>;
|
||||
await updateDashboardV2({ id: dashboardId }, dashboardToUpdatable(parsed));
|
||||
toast.success('Dashboard updated');
|
||||
refetch();
|
||||
onApplied();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
dashboardId,
|
||||
validity.valid,
|
||||
isDirty,
|
||||
draft,
|
||||
refetch,
|
||||
onApplied,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
return {
|
||||
draft,
|
||||
setDraft,
|
||||
validity,
|
||||
isDirty,
|
||||
isSaving,
|
||||
format,
|
||||
reset,
|
||||
apply,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -12,12 +12,12 @@ import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
|
||||
@@ -50,9 +50,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
const createPanel = useCreatePanel();
|
||||
const [isAddingPanel, setIsAddingPanel] = useState(false);
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
@@ -108,8 +107,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
void logEvent('Dashboard Detail V2: Add new panel clicked', {
|
||||
dashboardId: id,
|
||||
});
|
||||
setIsPanelTypeSelectionModalOpen(true);
|
||||
}, [id, setIsPanelTypeSelectionModalOpen]);
|
||||
setIsAddingPanel(true);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<section className={styles.dashboardPageToolbarContainer}>
|
||||
@@ -140,15 +139,15 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2: the time selector floats top-right (declared first so the
|
||||
variables bar's content wraps around it); the variables bar
|
||||
collapses to one line and, when expanded, wraps full-width under it. */}
|
||||
<div className={styles.toolbarRow2}>
|
||||
<div className={styles.timeCluster}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
</div>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
<PanelTypeSelectionModal
|
||||
open={isAddingPanel}
|
||||
onClose={(): void => setIsAddingPanel(false)}
|
||||
onSelect={(pluginKind): void => {
|
||||
setIsAddingPanel(false);
|
||||
createPanel({ pluginKind });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ interface ConfigPaneProps {
|
||||
/** The panel spec — the single editing surface (title/description + section slices). */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Switch the panel to another visualization kind. */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Panel's resolved series, provided to sections that need them (legend colors). */
|
||||
legendSeries: LegendSeries[];
|
||||
/** Table panel's resolved value columns, for the table-only editors. */
|
||||
@@ -36,6 +38,7 @@ function ConfigPane({
|
||||
panelKind,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
@@ -95,6 +98,8 @@ function ConfigPane({
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getPanelDefinition } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
panelKind: PanelKind;
|
||||
/** Panel's current datasource — drives the disabled rule. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
onChange: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-type selector (rendered inside the Visualization section). Types whose
|
||||
* supported signals exclude the panel's current datasource are disabled (V1 parity —
|
||||
* e.g. List needs logs/traces, not metrics). The datasource is unknown for
|
||||
* PromQL/ClickHouse queries, in which case no type is disabled.
|
||||
*/
|
||||
function PanelTypeSwitcher({
|
||||
panelKind,
|
||||
signal,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = PANEL_TYPES.map((type) => {
|
||||
const definition = getPanelDefinition(type.pluginKind as PanelKind);
|
||||
return {
|
||||
value: type.pluginKind,
|
||||
label: type.label,
|
||||
icon: type.icon,
|
||||
disabled:
|
||||
!!signal && !!definition && !definition.supportedSignals.includes(signal),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel Type</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-type-switcher"
|
||||
value={panelKind}
|
||||
items={items}
|
||||
onChange={(value): void => onChange(value as PanelKind)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTypeSwitcher;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelTypeSwitcher from '../PanelTypeSwitcher';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
|
||||
function openDropdown(): void {
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
}
|
||||
|
||||
describe('PanelTypeSwitcher', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
supportedSignals:
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
: ['metrics', 'logs', 'traces'],
|
||||
}));
|
||||
});
|
||||
|
||||
it('fires onChange with the chosen plugin kind', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PanelTypeSwitcher panelKind="signoz/TimeSeriesPanel" onChange={onChange} />,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
fireEvent.click(screen.getByText('List'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
|
||||
});
|
||||
|
||||
it('disables types whose supported signals exclude the current datasource', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
const disabled = Array.from(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).map((el) => el.textContent);
|
||||
|
||||
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
|
||||
expect(disabled).toContain('List');
|
||||
expect(disabled).not.toContain('Time Series');
|
||||
});
|
||||
|
||||
it('does not disable any type when the datasource is unknown', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SectionConfig,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import type { LegendSeries } from '../../hooks/useLegendSeries';
|
||||
import type { TableColumnOption } from '../../hooks/useTableColumns';
|
||||
import { resolveSectionEditor } from '../sectionRegistry';
|
||||
@@ -23,6 +24,9 @@ interface SectionSlotProps {
|
||||
tableColumns: TableColumnOption[];
|
||||
/** Panel's telemetry signal, for editors that fetch field suggestions (List columns). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** Current panel kind + switch handler, for the visualization section's type switcher. */
|
||||
panelKind: PanelKind;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +42,8 @@ function SectionSlot({
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
}: SectionSlotProps): JSX.Element | null {
|
||||
// A kind can hide a section based on current spec state (e.g. Histogram legend once
|
||||
// queries are merged) — skip it before resolving the editor.
|
||||
@@ -60,7 +66,12 @@ function SectionSlot({
|
||||
.formatting?.unit;
|
||||
|
||||
return (
|
||||
<SettingsSection title={title} icon={<Icon size={15} />}>
|
||||
<SettingsSection
|
||||
title={title}
|
||||
icon={<Icon size={15} />}
|
||||
// Open Visualization by default so the type switcher is visible.
|
||||
defaultOpen={config.kind === 'visualization'}
|
||||
>
|
||||
<Component
|
||||
value={get(spec)}
|
||||
controls={controls}
|
||||
@@ -69,6 +80,8 @@ function SectionSlot({
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ function renderConfigPane(
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
spec: spec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
legendSeries: [],
|
||||
tableColumns: [],
|
||||
...overrides,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.group {
|
||||
width: min(350px, 100%);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segment {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
|
||||
@@ -7,7 +8,9 @@ import styles from './ConfigSelect.module.scss';
|
||||
export interface ConfigSelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: SegmentIconName;
|
||||
/** A `SegmentIconName` string (resolved to a glyph), or an arbitrary icon node. */
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ConfigSelectProps {
|
||||
@@ -40,9 +43,14 @@ function ConfigSelect({
|
||||
virtual={false}
|
||||
options={items.map((item) => ({
|
||||
value: item.value,
|
||||
disabled: item.disabled,
|
||||
label: item.icon ? (
|
||||
<span className={styles.item}>
|
||||
<SegmentIcon name={item.icon} />
|
||||
{typeof item.icon === 'string' ? (
|
||||
<SegmentIcon name={item.icon as SegmentIconName} />
|
||||
) : (
|
||||
item.icon
|
||||
)}
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -157,6 +157,10 @@ export interface ErasedSectionDescriptor {
|
||||
// The panel's telemetry signal; read by editors that fetch field-key
|
||||
// suggestions scoped to it (List column picker).
|
||||
signal?: unknown;
|
||||
// Current panel kind + switch handler; read by the visualization section's
|
||||
// type switcher.
|
||||
panelKind?: unknown;
|
||||
onChangePanelKind?: unknown;
|
||||
}>;
|
||||
get: (spec: PanelSpec) => unknown;
|
||||
update: (spec: PanelSpec, value: unknown) => PanelSpec;
|
||||
|
||||
@@ -9,8 +9,8 @@ const THRESHOLDS: DashboardtypesThresholdWithLabelDTO[] = [
|
||||
{ value: 80, color: '#F5B225', label: 'High', unit: 'percent' },
|
||||
];
|
||||
|
||||
// Stateful harness for flows that depend on the value updating (add/discard). No
|
||||
// `controls` is passed, exercising the default `label` variant.
|
||||
// Stateful harness for flows that depend on the value updating (add/discard);
|
||||
// omits `controls` to exercise the default `label` variant.
|
||||
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
|
||||
const [value, setValue] = useState<AnyThreshold[]>([]);
|
||||
return (
|
||||
@@ -33,7 +33,6 @@ describe('ThresholdsSection', () => {
|
||||
|
||||
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
|
||||
expect(screen.getByText('High')).toBeInTheDocument();
|
||||
// The editable fields are hidden until the row is edited.
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -54,6 +53,21 @@ describe('ThresholdsSection', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('persists an empty-string label when none is provided', () => {
|
||||
const onChange = jest.fn();
|
||||
// Label absent (e.g. a pre-existing spec); spec requires a string, so save
|
||||
// must send '' not undefined.
|
||||
const noLabel = [{ value: 50, color: '#F1575F' }] as AnyThreshold[];
|
||||
render(<ThresholdsSection value={noLabel} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-edit-0'));
|
||||
fireEvent.click(screen.getByTestId('threshold-save-0'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ value: 50, color: '#F1575F', label: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not commit edits when Discard is clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
|
||||
@@ -65,7 +79,6 @@ describe('ThresholdsSection', () => {
|
||||
fireEvent.click(screen.getByTestId('threshold-discard-0'));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
// Back to view mode.
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
|
||||
});
|
||||
@@ -83,11 +96,10 @@ describe('ThresholdsSection', () => {
|
||||
render(<Harness />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-threshold'));
|
||||
// New row opens in edit mode.
|
||||
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-discard-0'));
|
||||
// Discarding a never-saved row removes it entirely.
|
||||
fireEvent.click(screen.getByTestId('threshold-discard-0'));
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -23,10 +23,7 @@ interface LabelThresholdRowProps {
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value + color + label threshold (TimeSeries / Bar): a line drawn on the chart. Edit
|
||||
* form is color, value, unit, label.
|
||||
*/
|
||||
/** Value + color + label threshold (TimeSeries / Bar): a line drawn on the chart. */
|
||||
function LabelThresholdRow({
|
||||
index,
|
||||
threshold,
|
||||
@@ -58,7 +55,7 @@ function LabelThresholdRow({
|
||||
isEditing={isEditing}
|
||||
summary={summary}
|
||||
onEdit={onEdit}
|
||||
onSave={(): void => onSave(draft)}
|
||||
onSave={(): void => onSave({ ...draft, label: draft.label ?? '' })}
|
||||
onDiscard={onDiscard}
|
||||
onRemove={onRemove}
|
||||
>
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import type { PanelKind } from '../../../../Panels/types/panelKind';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
|
||||
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
|
||||
|
||||
import styles from './VisualizationSection.module.scss';
|
||||
|
||||
type VisualizationSectionProps = SectionEditorProps<'visualization'> & {
|
||||
/** Current panel kind + switch handler, forwarded by SectionSlot for the type switcher. */
|
||||
panelKind?: PanelKind;
|
||||
onChangePanelKind?: (kind: PanelKind) => void;
|
||||
/** Panel's datasource, forwarded by SectionSlot — scopes the switcher's disabled types. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits the `visualization` slice: the per-panel time preference (all kinds), bar
|
||||
* stacking (`stackedBarChart`, Bar only), and gap filling (`fillSpans`, TimeSeries
|
||||
* only). Each control is gated by its `controls` flag, so a kind only renders — and only
|
||||
* writes — the visualization fields its spec actually supports.
|
||||
* Edits the `visualization` slice: the panel-type switcher (`switchPanelKind`, every
|
||||
* kind), the per-panel time preference, bar stacking (`stackedBarChart`, Bar only), and
|
||||
* gap filling (`fillSpans`, TimeSeries only). Each control is gated by its `controls`
|
||||
* flag, so a kind only renders — and only writes — the fields its spec supports.
|
||||
*/
|
||||
function VisualizationSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<'visualization'>): JSX.Element {
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
signal,
|
||||
}: VisualizationSectionProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{controls.switchPanelKind && panelKind && onChangePanelKind && (
|
||||
<PanelTypeSwitcher
|
||||
panelKind={panelKind}
|
||||
signal={signal}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.timePreference && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel time preference</Typography.Text>
|
||||
|
||||
@@ -4,6 +4,14 @@ import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.s
|
||||
|
||||
import VisualizationSection from '../VisualizationSection';
|
||||
|
||||
// The type switcher resolves each kind's supported signals; stub it so the test
|
||||
// doesn't pull the whole panel registry (renderers, chart libs).
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(() => ({
|
||||
supportedSignals: ['metrics', 'logs', 'traces'],
|
||||
})),
|
||||
}));
|
||||
|
||||
// Open the antd Select by clicking its selector, then pick the option by label.
|
||||
async function pickOption(triggerTestId: string, label: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
@@ -17,7 +25,12 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true, stacking: true, fillSpans: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
stacking: true,
|
||||
fillSpans: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -35,7 +48,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -56,7 +72,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -74,7 +93,10 @@ describe('VisualizationSection', () => {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
stackedBarChart: false,
|
||||
}}
|
||||
controls={{ stacking: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
stacking: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -92,7 +114,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={{ fillSpans: false }}
|
||||
controls={{ fillSpans: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
fillSpans: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -101,4 +126,43 @@ describe('VisualizationSection', () => {
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
|
||||
});
|
||||
|
||||
it('renders the type switcher and switches kind when switchPanelKind is set', async () => {
|
||||
const onChangePanelKind = jest.fn();
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ switchPanelKind: true }}
|
||||
onChange={jest.fn()}
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-type-switcher'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await pickOption('panel-editor-v2-type-switcher', 'Table');
|
||||
expect(onChangePanelKind).toHaveBeenCalledWith('signoz/TablePanel');
|
||||
});
|
||||
|
||||
it('hides the type switcher when switchPanelKind is not set', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{
|
||||
switchPanelKind: false,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChangePanelKind={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-type-switcher'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type {
|
||||
DashboardtypesListPanelSpecDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
import {
|
||||
type DashboardtypesListPanelSpecDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
type TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
|
||||
/**
|
||||
* The field-key suggestions API and the default-column constants carry extra
|
||||
* runtime fields (e.g. `isIndexed`) that the save contract rejects. Reduce each
|
||||
* column to the `TelemetrytypesTelemetryFieldKeyDTO` shape so persisted
|
||||
* `selectFields` only contain backend-known keys.
|
||||
* Reduce each column to the field-key DTO shape: the suggestions API and default
|
||||
* constants carry extra runtime fields (e.g. `isIndexed`) the save contract rejects.
|
||||
*/
|
||||
function toFieldKeyDTO(
|
||||
field: TelemetrytypesTelemetryFieldKeyDTO,
|
||||
@@ -31,10 +34,26 @@ export function sanitizeSelectFields(
|
||||
}
|
||||
|
||||
/**
|
||||
* `spec.plugin.spec` is a discriminated union over every panel kind; these helpers
|
||||
* run only for the List panel, so it's narrowed to the List variant with a single
|
||||
* localized cast at the boundary.
|
||||
* logs/traces List-column defaults (V1 parity), sanitized to the field-key DTO.
|
||||
*/
|
||||
export function defaultColumnsForSignal(
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO[] {
|
||||
if (signal === TelemetrytypesSignalDTO.logs) {
|
||||
return sanitizeSelectFields(
|
||||
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
}
|
||||
if (signal === TelemetrytypesSignalDTO.traces) {
|
||||
return sanitizeSelectFields(
|
||||
defaultTraceSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// `spec.plugin.spec` is a discriminated union over panel kinds; these List-only
|
||||
// helpers narrow to the List variant via a single localized cast at the boundary.
|
||||
export function readSelectFields(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO[] {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Spline } from '@signozhq/icons';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
interface PlotTagProps {
|
||||
/** Authoring mode of the panel's query; undefined when no query exists yet. */
|
||||
queryType: EQueryType | undefined;
|
||||
panelType: PANEL_TYPES;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Plotted with <query mode>" chip for the editor preview; V2 counterpart of V1's
|
||||
* PlotTag (duplicated per the split policy). Hidden for list panels and before a
|
||||
* query exists, where the mode is irrelevant.
|
||||
*/
|
||||
function PlotTag({
|
||||
queryType,
|
||||
panelType,
|
||||
className,
|
||||
}: PlotTagProps): JSX.Element | null {
|
||||
if (queryType === undefined || panelType === PANEL_TYPES.LIST) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} data-testid="panel-editor-plot-tag">
|
||||
<Spline size={14} />
|
||||
Plotted with <QueryTypeTag queryType={queryType} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlotTag;
|
||||
@@ -43,8 +43,10 @@
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
// Header stacks above the body, flush to the border — mirrors the dashboard
|
||||
// grid's `.panel` so the preview reads as the real panel chrome.
|
||||
flex-direction: column;
|
||||
background: var(--l2-background);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.state {
|
||||
@@ -57,3 +59,7 @@
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dateTimeSelector {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { Spline } from '@signozhq/icons';
|
||||
import { useState } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PlotTag from './PlotTag';
|
||||
import styles from './PreviewPane.module.scss';
|
||||
|
||||
interface PreviewPaneProps {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Resolved definition for the panel kind; undefined when the kind is unsupported. */
|
||||
panelDef: RenderablePanelDefinition | undefined;
|
||||
/** Resolved definition for the panel kind; */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
/** Background refresh in flight — drives the header's subtle refetch spinner. */
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
/** Re-run the query (drives PanelBody's error-state retry). */
|
||||
refetch: () => void;
|
||||
@@ -30,50 +34,70 @@ interface PreviewPaneProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview for the panel editor. Renders the draft through the same `PanelBody`
|
||||
* the dashboard grid uses (only `panelMode={DASHBOARD_EDIT}` differs), so the preview
|
||||
* is the production render path. The query result is owned by the editor root.
|
||||
* Live preview for the panel editor: renders the draft through the same `PanelBody`
|
||||
* the dashboard grid uses (only `panelMode` differs), so the preview is the
|
||||
* production render path. The query result is owned by the editor root.
|
||||
*/
|
||||
function PreviewPane({
|
||||
panelId,
|
||||
panel,
|
||||
panelDef,
|
||||
panelDefinition,
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
pagination,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
const queryType = getPanelQueryType(panel);
|
||||
|
||||
// Search term is ephemeral preview state, threaded to header + renderer but
|
||||
// not persisted to the draft spec. Only kinds that declare it render the box.
|
||||
const searchable = !!panelDefinition.actions.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.queryType}>
|
||||
<Spline size={14} />
|
||||
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
|
||||
<PlotTag
|
||||
queryType={queryType}
|
||||
panelType={panelType}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.surface}>
|
||||
{panelDef ? (
|
||||
<PanelBody
|
||||
panelDefinition={panelDef}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
pagination={pagination}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
|
||||
This panel type is not yet supported in V2.
|
||||
</div>
|
||||
)}
|
||||
<PanelHeader
|
||||
name={panel.spec.display.name}
|
||||
description={panel.spec.display?.description}
|
||||
panelId={panelId}
|
||||
panelKind={panel.spec.plugin.kind}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
searchable={searchable}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PlotTag from '../PlotTag';
|
||||
|
||||
describe('PlotTag', () => {
|
||||
it('renders the resolved query mode', () => {
|
||||
render(
|
||||
<PlotTag queryType={EQueryType.PROM} panelType={PANEL_TYPES.TIME_SERIES} />,
|
||||
);
|
||||
expect(screen.getByTestId('panel-editor-plot-tag')).toBeInTheDocument();
|
||||
expect(screen.getByText('PromQL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when there is no query yet', () => {
|
||||
render(<PlotTag queryType={undefined} panelType={PANEL_TYPES.TIME_SERIES} />);
|
||||
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing for list panels (query mode is irrelevant)', () => {
|
||||
render(
|
||||
<PlotTag
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
panelType={PANEL_TYPES.LIST}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import { defaultColumnsForSignal } from '../ListColumnsEditor/selectFields';
|
||||
import { getSwitchedPluginSpec } from '../getSwitchedPluginSpec';
|
||||
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
}));
|
||||
jest.mock('../ListColumnsEditor/selectFields', () => ({
|
||||
defaultColumnsForSignal: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
const mockDefaultColumnsForSignal =
|
||||
defaultColumnsForSignal as unknown as jest.Mock;
|
||||
|
||||
function specWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind: 'signoz/TablePanel', spec: pluginSpec },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
describe('getSwitchedPluginSpec', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDefaultColumnsForSignal.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('carries only unit + decimalPrecision when the new kind has a formatting section', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'formatting', controls: { unit: true, decimals: true } }],
|
||||
});
|
||||
const old = specWith({
|
||||
formatting: { unit: 'ms', decimalPrecision: 2, columnUnits: { A: 'bytes' } },
|
||||
axes: { logScale: true },
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(old, 'signoz/TimeSeriesPanel');
|
||||
|
||||
expect(result.formatting).toStrictEqual({ unit: 'ms', decimalPrecision: 2 });
|
||||
// Type-specific config from the old kind is dropped.
|
||||
expect((result as { axes?: unknown }).axes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not carry formatting when the new kind has no formatting section', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
|
||||
const old = specWith({ formatting: { unit: 'ms' } });
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
'signoz/ListPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.formatting).toBeUndefined();
|
||||
});
|
||||
|
||||
it('seeds List columns from the signal when switching into a List', () => {
|
||||
const columns = [{ name: 'body' }];
|
||||
mockDefaultColumnsForSignal.mockReturnValue(columns);
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
specWith({}),
|
||||
'signoz/ListPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(mockDefaultColumnsForSignal).toHaveBeenCalledWith(
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
expect(result.selectFields).toBe(columns);
|
||||
});
|
||||
|
||||
it('includes the kind section defaults (e.g. legend position)', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'legend', controls: { position: true } }],
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(specWith({}), 'signoz/PieChartPanel');
|
||||
|
||||
expect(result.legend?.position).toBe('bottom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
NEW_PANEL_ID,
|
||||
newPanelSearch,
|
||||
parseNewPanelKind,
|
||||
parseNewPanelLayoutIndex,
|
||||
} from '../newPanelRoute';
|
||||
|
||||
describe('newPanelRoute', () => {
|
||||
it('round-trips kind + layoutIndex through the new-panel search', () => {
|
||||
const search = newPanelSearch('signoz/ListPanel', 2);
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe('signoz/ListPanel');
|
||||
expect(parseNewPanelLayoutIndex(search)).toBe(2);
|
||||
});
|
||||
|
||||
it('omits layoutIndex when not provided', () => {
|
||||
const search = newPanelSearch('signoz/TimeSeriesPanel');
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
expect(parseNewPanelLayoutIndex(search)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns null for an existing panel id (not the new sentinel)', () => {
|
||||
const search = newPanelSearch('signoz/ListPanel');
|
||||
expect(parseNewPanelKind('a1b2c3d4-uuid', search)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the kind param is missing or unknown', () => {
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, '')).toBeNull();
|
||||
expect(
|
||||
parseNewPanelKind(NEW_PANEL_ID, '?panelKind=NotARealPanel'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { PanelFormattingSlice } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import {
|
||||
buildDefaultPluginSpec,
|
||||
type DefaultPluginSpec,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildDefaultPluginSpec';
|
||||
|
||||
import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
|
||||
|
||||
/**
|
||||
* Plugin spec produced on a first-time switch to a new kind. A partial cross-section
|
||||
* of the per-kind spec union; the caller assigns it to `plugin.spec` (typed `unknown`)
|
||||
* at the boundary.
|
||||
*/
|
||||
export interface SwitchedPluginSpec extends DefaultPluginSpec {
|
||||
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
|
||||
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
|
||||
* the only cross-kind config worth keeping — unit + decimal precision. Switching into a
|
||||
* List seeds the current signal's default columns so the columns control isn't empty.
|
||||
*
|
||||
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
|
||||
*/
|
||||
export function getSwitchedPluginSpec(
|
||||
oldSpec: DashboardtypesPanelSpecDTO,
|
||||
newKind: PanelKind,
|
||||
signal?: TelemetrytypesSignalDTO,
|
||||
): SwitchedPluginSpec {
|
||||
const sections = getPanelDefinition(newKind)?.sections ?? [];
|
||||
const result: SwitchedPluginSpec = buildDefaultPluginSpec(sections);
|
||||
|
||||
if (sections.some((section) => section.kind === 'formatting')) {
|
||||
const oldFormatting = (
|
||||
oldSpec.plugin.spec as {
|
||||
formatting?: PanelFormattingSlice;
|
||||
}
|
||||
).formatting;
|
||||
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
|
||||
...(oldFormatting?.unit !== undefined && { unit: oldFormatting.unit }),
|
||||
...(oldFormatting?.decimalPrecision !== undefined && {
|
||||
decimalPrecision: oldFormatting.decimalPrecision,
|
||||
}),
|
||||
};
|
||||
if (Object.keys(carried).length > 0) {
|
||||
result.formatting = carried;
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.some((section) => section.kind === 'columns') && signal) {
|
||||
const columns = defaultColumnsForSignal(signal);
|
||||
if (columns.length > 0) {
|
||||
result.selectFields = columns;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { handleQueryChange } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
|
||||
import { usePanelTypeSwitch } from '../usePanelTypeSwitch';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('container/NewWidget/utils', () => ({
|
||||
handleQueryChange: jest.fn(),
|
||||
PANEL_TYPE_TO_QUERY_TYPES: {
|
||||
graph: ['builder', 'clickhouse', 'promql'],
|
||||
table: ['builder', 'clickhouse'],
|
||||
list: ['builder'],
|
||||
value: ['builder', 'clickhouse', 'promql'],
|
||||
bar: ['builder', 'clickhouse', 'promql'],
|
||||
pie: ['builder', 'clickhouse'],
|
||||
histogram: ['builder', 'clickhouse', 'promql'],
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
|
||||
toPerses: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../getSwitchedPluginSpec', () => ({
|
||||
getSwitchedPluginSpec: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
|
||||
getBuilderQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
|
||||
const mockToPerses = toPerses as unknown as jest.Mock;
|
||||
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
|
||||
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
|
||||
|
||||
// Opaque sentinels — the leaf utilities are mocked, so only identity matters.
|
||||
const TABLE_PLUGIN_SPEC = { table: true } as unknown;
|
||||
const TABLE_QUERIES = [{ id: 'table-q' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const LIST_PLUGIN_SPEC = { list: true } as unknown;
|
||||
const LIST_QUERIES = [{ id: 'list-q' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const TRANSFORMED = {
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
} as unknown as Query;
|
||||
const CONVERTED = [{ id: 'converted' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const SWITCHED_SPEC = { switched: true } as unknown;
|
||||
|
||||
function makeSpec(
|
||||
kind: string,
|
||||
pluginSpec: unknown,
|
||||
queries: NonNullable<DashboardtypesPanelSpecDTO['queries']>,
|
||||
): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind, spec: pluginSpec },
|
||||
queries,
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
const tableSpec = makeSpec(
|
||||
'signoz/TablePanel',
|
||||
TABLE_PLUGIN_SPEC,
|
||||
TABLE_QUERIES,
|
||||
);
|
||||
const listSpec = makeSpec('signoz/ListPanel', LIST_PLUGIN_SPEC, LIST_QUERIES);
|
||||
|
||||
function builderState(currentQuery: Query): {
|
||||
currentQuery: Query;
|
||||
redirectWithQueryBuilderData: jest.Mock;
|
||||
} {
|
||||
return { currentQuery, redirectWithQueryBuilderData: jest.fn() };
|
||||
}
|
||||
|
||||
describe('usePanelTypeSwitch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
|
||||
mockToPerses.mockReturnValue(CONVERTED);
|
||||
mockGetSwitchedPluginSpec.mockReturnValue(SWITCHED_SPEC);
|
||||
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
|
||||
});
|
||||
|
||||
it('does nothing when switching to the current kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
expect(state.redirectWithQueryBuilderData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('on first visit: transforms the query and resets the spec to the new kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
expect(setSpec).toHaveBeenCalledTimes(1);
|
||||
const next = setSpec.mock.calls[0][0] as DashboardtypesPanelSpecDTO;
|
||||
expect(next.plugin.kind).toBe('signoz/ListPanel');
|
||||
expect(next.plugin.spec).toBe(SWITCHED_SPEC);
|
||||
expect(next.queries).toBe(CONVERTED);
|
||||
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(TRANSFORMED);
|
||||
});
|
||||
|
||||
it('coerces the query type when the new kind disallows it (promql → List)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
|
||||
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// List allows only Query Builder, so the promql query is coerced to 'builder'.
|
||||
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
|
||||
expect((queryArg as Query).queryType).toBe('builder');
|
||||
});
|
||||
|
||||
it('restores the original kind verbatim on switch-back (reversibility)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
|
||||
let state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
|
||||
usePanelTypeSwitch({ ...props, setSpec }),
|
||||
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
|
||||
);
|
||||
|
||||
// Leave Table for List (stashes Table in its pristine state).
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// Parent re-renders as a List panel; the builder now holds the List query.
|
||||
state = builderState(listQuery);
|
||||
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
|
||||
|
||||
// Switch back to Table → restored from the stash, not re-transformed.
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
const restored = setSpec.mock.calls[
|
||||
setSpec.mock.calls.length - 1
|
||||
][0] as DashboardtypesPanelSpecDTO;
|
||||
expect(restored.plugin.kind).toBe('signoz/TablePanel');
|
||||
expect(restored.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
|
||||
expect(restored.queries).toBe(TABLE_QUERIES);
|
||||
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(tableQuery);
|
||||
// The restore path must not run the query transform again.
|
||||
expect(mockHandleQueryChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
import { sanitizeSelectFields } from '../../ListColumnsEditor/selectFields';
|
||||
import { useSwitchColumnsOnSignalChange } from '../useSwitchColumnsOnSignalChange';
|
||||
|
||||
// The hook applies the datasource defaults reduced to the field-key DTO (the V1
|
||||
// constants carry extra keys like `isIndexed`); assertions mirror that.
|
||||
// V1 constants carry extra keys (e.g. `isIndexed`); the hook reduces them to the
|
||||
// field-key DTO, so assertions sanitize the same way.
|
||||
const expectedLogs = sanitizeSelectFields(
|
||||
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
@@ -73,6 +73,45 @@ describe('useSwitchColumnsOnSignalChange', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the original columns on logs → traces → logs', () => {
|
||||
// Customized logs selection, not the timestamp/body defaults.
|
||||
const original = [
|
||||
{ name: 'timestamp' },
|
||||
{ name: 'body' },
|
||||
{ name: 'response_status_code' },
|
||||
{ name: 'trace_id' },
|
||||
];
|
||||
// Mirror the real parent: persist the spec so the next switch stashes the
|
||||
// columns the previous one applied.
|
||||
let spec = makeSpec(original);
|
||||
const onChangeSpec = jest.fn((next: DashboardtypesPanelSpecDTO) => {
|
||||
spec = next;
|
||||
});
|
||||
const { rerender } = renderWith({
|
||||
enabled: true,
|
||||
signal: TelemetrytypesSignalDTO.logs,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
});
|
||||
|
||||
rerender({
|
||||
enabled: true,
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
});
|
||||
expect(selectFieldsOf(spec)).toStrictEqual(expectedTraces);
|
||||
|
||||
// Switching back restores the original columns, not the log defaults.
|
||||
rerender({
|
||||
enabled: true,
|
||||
signal: TelemetrytypesSignalDTO.logs,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
});
|
||||
expect(selectFieldsOf(spec)).toStrictEqual(original);
|
||||
});
|
||||
|
||||
it('switches to the log defaults when going traces → logs', () => {
|
||||
const onChangeSpec = jest.fn();
|
||||
const spec = makeSpec([{ name: 'service.name' }]);
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getIsQueryModified } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
@@ -19,14 +20,24 @@ interface UsePanelEditorQuerySyncArgs {
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Re-fetch the preview when the query is unchanged (Stage & Run on a no-op). */
|
||||
refetch: () => void;
|
||||
/**
|
||||
* Serialize the live query on save even when unchanged. Set for a new panel,
|
||||
* whose seed query is the builder default (not a real saved query).
|
||||
*/
|
||||
alwaysSerializeQuery?: boolean;
|
||||
/**
|
||||
* Datasource to seed a new panel's builder with — the panel kind's first
|
||||
* supported signal (e.g. List supports only logs/traces, not metrics).
|
||||
*/
|
||||
defaultDataSource?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
interface UsePanelEditorQuerySyncApi {
|
||||
/** Run the current query (Stage & Run / ⌘↵). */
|
||||
runQuery: () => void;
|
||||
/** True when the live builder query differs from the saved query (compared builder-normalized to avoid re-serialization noise). */
|
||||
/** True when the live builder query differs from the saved query. */
|
||||
isQueryDirty: boolean;
|
||||
/** Bake the live query into a spec for saving so unstaged edits persist; returns the spec untouched when unchanged. */
|
||||
/** Bake the live query into a spec so unstaged edits persist; unchanged → spec untouched. */
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
@@ -34,27 +45,36 @@ interface UsePanelEditorQuerySyncApi {
|
||||
|
||||
/**
|
||||
* Bridges the shared (URL-synced) query builder and the V2 editor draft: seeds the
|
||||
* builder from the saved panel, then commits the active query into `draft.spec.queries`
|
||||
* (what the preview fetches) on a query-type/datasource switch and on Stage & Run.
|
||||
* builder from the saved panel, then commits the active query into
|
||||
* `draft.spec.queries` (what the preview fetches) on a query-type/datasource switch
|
||||
* and on Stage & Run.
|
||||
*/
|
||||
export function usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
alwaysSerializeQuery = false,
|
||||
defaultDataSource,
|
||||
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
|
||||
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
|
||||
|
||||
// Saved queries, captured once: seed the builder and serve as the restore target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
|
||||
const savedQueries = useMemo(() => draft.spec?.queries ?? [], []);
|
||||
// A new panel has no saved query: seed from the kind's first supported signal
|
||||
// instead of letting `fromPerses` fall back to the metrics default (which List
|
||||
// doesn't support).
|
||||
const seedQuery = useMemo(
|
||||
() => fromPerses(savedQueries, panelType),
|
||||
[savedQueries, panelType],
|
||||
() =>
|
||||
savedQueries.length === 0 && defaultDataSource
|
||||
? initialQueriesMap[defaultDataSource]
|
||||
: fromPerses(savedQueries, panelType),
|
||||
[savedQueries, panelType, defaultDataSource],
|
||||
);
|
||||
// Force-reset the builder to the SAVED panel on first render only, discarding any
|
||||
// stale URL query from a prior edit — otherwise the QB and preview diverge and the
|
||||
// dirty baseline gets captured from the URL. After mount the URL syncs normally.
|
||||
// Force-reset the builder to the SAVED panel on first render only, discarding a
|
||||
// stale URL query from a prior edit (else the QB/preview diverge and the dirty
|
||||
// baseline is captured from the URL). After mount the URL syncs normally.
|
||||
const isInitialRenderRef = useRef(true);
|
||||
useShareBuilderUrl({
|
||||
defaultValue: seedQuery,
|
||||
@@ -64,11 +84,10 @@ export function usePanelEditorQuerySync({
|
||||
isInitialRenderRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Commit the live query into the draft (what the preview fetches). The dirty check
|
||||
// compares against the SAVED query (`seedQuery`), not the URL-synced staged query,
|
||||
// which can carry stale state across a refresh and make a real switch read as
|
||||
// "unchanged". Unchanged → restore saved queries; changed → commit. Returns whether
|
||||
// the draft changed.
|
||||
// Commit the live query into the draft (what the preview fetches). The dirty
|
||||
// check compares against the SAVED query (`seedQuery`), not the URL-synced
|
||||
// staged query, which can carry stale state across a refresh and read a real
|
||||
// switch as "unchanged". Returns whether the draft changed.
|
||||
const commitQuery = useCallback(
|
||||
(query: Query): boolean => {
|
||||
const next = getIsQueryModified(query, seedQuery)
|
||||
@@ -76,7 +95,7 @@ export function usePanelEditorQuerySync({
|
||||
: savedQueries;
|
||||
// No-op guard at the V5 envelope level: equivalent wrappers (bare
|
||||
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
|
||||
// envelopes, so comparing them structurally would falsely dirty the draft.
|
||||
// envelopes, so a structural compare would falsely dirty the draft.
|
||||
const current = draft.spec?.queries ?? [];
|
||||
if (isEqual(toQueryEnvelopes(next), toQueryEnvelopes(current))) {
|
||||
return false;
|
||||
@@ -93,8 +112,8 @@ export function usePanelEditorQuerySync({
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
|
||||
// Re-commit on a query-type or datasource switch so the preview refetches. Skip
|
||||
// mount: the draft already holds the saved queries the builder is force-reset to.
|
||||
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
|
||||
// mount: the draft already holds the saved queries the builder is reset to.
|
||||
const dataSourceSignature = useMemo(
|
||||
() =>
|
||||
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
|
||||
@@ -119,10 +138,10 @@ export function usePanelEditorQuerySync({
|
||||
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
|
||||
|
||||
// Dirty baseline: the builder's OWN normalized saved query (first non-null
|
||||
// `stagedQuery` after the mount reset). Comparing builder-normalized to
|
||||
// `stagedQuery` after the mount reset) — comparing builder-normalized to
|
||||
// builder-normalized avoids serialization drift reading an untouched query as
|
||||
// modified. Held in state (not a ref) so capture re-triggers `isQueryDirty`;
|
||||
// captured once and never moved by Stage & Run, so it stays anchored to saved.
|
||||
// modified. In state (not a ref) so capture re-triggers `isQueryDirty`; captured
|
||||
// once and never moved by Stage & Run, so it stays anchored to saved.
|
||||
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
|
||||
useEffect(() => {
|
||||
if (queryBaseline === null && stagedQuery) {
|
||||
@@ -135,10 +154,10 @@ export function usePanelEditorQuerySync({
|
||||
|
||||
const buildSaveSpec = useCallback(
|
||||
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
|
||||
isQueryDirty
|
||||
isQueryDirty || alwaysSerializeQuery
|
||||
? { ...spec, queries: toPerses(currentQuery, panelType) }
|
||||
: spec,
|
||||
[isQueryDirty, currentQuery, panelType],
|
||||
[isQueryDirty, alwaysSerializeQuery, currentQuery, panelType],
|
||||
);
|
||||
|
||||
return { runQuery, isQueryDirty, buildSaveSpec };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
@@ -7,12 +8,20 @@ import {
|
||||
import {
|
||||
type DashboardtypesJSONPatchOperationDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesPanelKindDTO,
|
||||
DashboardtypesPatchOpDTO,
|
||||
type GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { createPanelOps } from '../../patchOps';
|
||||
|
||||
interface UsePanelEditorSaveArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
/** Creating a new panel (vs editing an existing one) — adds panel + layout. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
@@ -22,34 +31,49 @@ interface UsePanelEditorSaveApi {
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists panel edits via a single RFC-6902 `add` op that replaces the whole panel
|
||||
* spec at `/spec/panels/{panelId}/spec`, so every config-pane edit is saved (not just
|
||||
* title/description). `add` doubles as create-or-replace, avoiding a separate
|
||||
* existence check.
|
||||
* Persists panel edits for the V2 editor via RFC-6902 JSON Patch. Editing: one
|
||||
* `add` op replaces the whole spec. Creating (`isNew`): mints a fresh id and adds
|
||||
* a grid item in the target section. Persists only on save — cancelling never
|
||||
* touches the dashboard.
|
||||
*/
|
||||
export function usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: DashboardtypesPatchOpDTO.add,
|
||||
path: `/spec/panels/${panelId}/spec`,
|
||||
value: spec,
|
||||
},
|
||||
];
|
||||
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
|
||||
let ops: DashboardtypesJSONPatchOperationDTO[];
|
||||
if (isNew) {
|
||||
// Resolve the target section against the freshest dashboard we have.
|
||||
const cached =
|
||||
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
|
||||
ops = createPanelOps({
|
||||
layouts: cached?.data.spec.layouts ?? [],
|
||||
layoutIndex,
|
||||
panelId: uuid(),
|
||||
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
|
||||
});
|
||||
} else {
|
||||
ops = [
|
||||
{
|
||||
op: DashboardtypesPatchOpDTO.add,
|
||||
path: `/spec/panels/${panelId}/spec`,
|
||||
value: spec,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
|
||||
await queryClient.invalidateQueries(
|
||||
getGetDashboardV2QueryKey({ id: dashboardId }),
|
||||
);
|
||||
await queryClient.invalidateQueries(dashboardQueryKey);
|
||||
},
|
||||
[dashboardId, panelId, mutateAsync, queryClient],
|
||||
[dashboardId, panelId, isNew, layoutIndex, mutateAsync, queryClient],
|
||||
);
|
||||
|
||||
return { save, isSaving: isLoading, error: (error as Error) ?? null };
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
handleQueryChange,
|
||||
PANEL_TYPE_TO_QUERY_TYPES,
|
||||
type PartialPanelTypes,
|
||||
} from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../../Panels/types/panelKind';
|
||||
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
import { getSwitchedPluginSpec } from '../getSwitchedPluginSpec';
|
||||
|
||||
/** What a kind looks like when you leave it; restored verbatim if you return. */
|
||||
interface KindState {
|
||||
pluginSpec: unknown;
|
||||
queries: DashboardtypesQueryDTO[] | null;
|
||||
builderQuery: Query;
|
||||
}
|
||||
|
||||
interface UsePanelTypeSwitchArgs {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
interface UsePanelTypeSwitchApi {
|
||||
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
|
||||
onChangePanelKind: (newKind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
|
||||
* renderer, config sections, query-builder tabs and request type for free; this hook adds
|
||||
* the two things that don't: a per-kind session cache that makes switching reversible
|
||||
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
|
||||
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
|
||||
*/
|
||||
export function usePanelTypeSwitch({
|
||||
spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
}: UsePanelTypeSwitchArgs): UsePanelTypeSwitchApi {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
|
||||
|
||||
// Latest spec/query/type, read inside the stable callback without re-subscribing.
|
||||
const specRef = useRef(spec);
|
||||
specRef.current = spec;
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
const panelTypeRef = useRef(panelType);
|
||||
panelTypeRef.current = panelType;
|
||||
|
||||
const onChangePanelKind = useCallback(
|
||||
(newKind: PanelKind): void => {
|
||||
const currentSpec = specRef.current;
|
||||
const oldKind = currentSpec.plugin.kind as PanelKind;
|
||||
if (newKind === oldKind) {
|
||||
return;
|
||||
}
|
||||
const query = queryRef.current;
|
||||
|
||||
cacheRef.current.set(oldKind, {
|
||||
pluginSpec: currentSpec.plugin.spec,
|
||||
queries: currentSpec.queries ?? null,
|
||||
builderQuery: query,
|
||||
});
|
||||
|
||||
const newPanelType = PANEL_KIND_TO_PANEL_TYPE[newKind];
|
||||
|
||||
// `plugin` is a discriminated union over `kind`; a dynamically-chosen kind
|
||||
// can't be expressed in the static union, so build the new spec and cast at
|
||||
// this boundary (as `createDefaultPanel` does).
|
||||
const buildSpec = (
|
||||
pluginSpec: unknown,
|
||||
queries: DashboardtypesQueryDTO[] | null,
|
||||
): DashboardtypesPanelSpecDTO =>
|
||||
({
|
||||
...currentSpec,
|
||||
plugin: { ...currentSpec.plugin, kind: newKind, spec: pluginSpec },
|
||||
queries,
|
||||
}) as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
// Revisit → restore the stash verbatim (the reversibility path).
|
||||
const cached = cacheRef.current.get(newKind);
|
||||
if (cached) {
|
||||
setSpec(buildSpec(cached.pluginSpec, cached.queries));
|
||||
redirectWithQueryBuilderData(cached.builderQuery);
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit → coerce the query type if the new panel disallows it, then
|
||||
// rebuild the builder query for the new type.
|
||||
const supported = PANEL_TYPE_TO_QUERY_TYPES[newPanelType] ?? [];
|
||||
const queryType = supported.includes(query.queryType)
|
||||
? query.queryType
|
||||
: supported[0];
|
||||
const transformed = handleQueryChange(
|
||||
newPanelType as keyof PartialPanelTypes,
|
||||
{ ...query, queryType },
|
||||
panelTypeRef.current,
|
||||
);
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
setSpec(
|
||||
buildSpec(
|
||||
getSwitchedPluginSpec(currentSpec, newKind, signal),
|
||||
toPerses(transformed, newPanelType),
|
||||
),
|
||||
);
|
||||
redirectWithQueryBuilderData(transformed);
|
||||
},
|
||||
[setSpec, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
return { onChangePanelKind };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
defaultColumnsForSignal,
|
||||
readSelectFields,
|
||||
writeSelectFields,
|
||||
} from '../ListColumnsEditor/selectFields';
|
||||
|
||||
interface UseSeedNewListColumnsArgs {
|
||||
/** Gate: a brand-new List panel (the only case that should auto-fill columns). */
|
||||
enabled: boolean;
|
||||
/** Default signal for the new panel — its kind's first supported signal. */
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a brand-new List panel's columns with its default signal's columns so the
|
||||
* Columns control isn't empty on first open. Runs once and only when empty: an
|
||||
* empty selection is a valid "show all fields" state, so existing panels and
|
||||
* user-cleared selections are never touched.
|
||||
*/
|
||||
export function useSeedNewListColumns({
|
||||
enabled,
|
||||
signal,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
}: UseSeedNewListColumnsArgs): void {
|
||||
const seededRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || seededRef.current || !signal) {
|
||||
return;
|
||||
}
|
||||
// Only seed when empty — don't clobber a selection that's already present.
|
||||
if (readSelectFields(spec).length > 0) {
|
||||
return;
|
||||
}
|
||||
seededRef.current = true;
|
||||
onChangeSpec(writeSelectFields(spec, defaultColumnsForSignal(signal)));
|
||||
}, [enabled, signal, spec, onChangeSpec]);
|
||||
}
|
||||
@@ -1,36 +1,16 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
type TelemetrytypesTelemetryFieldKeyDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
|
||||
import { sanitizeSelectFields } from '../ListColumnsEditor/selectFields';
|
||||
|
||||
/**
|
||||
* The datasource's default List columns (V1 parity), sanitized to the field-key
|
||||
* DTO — the V1 constants carry extra keys (isIndexed) the save contract rejects.
|
||||
* Other signals (metrics) don't produce a list, so they clear the selection.
|
||||
*/
|
||||
function defaultColumnsForSignal(
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO[] {
|
||||
if (signal === TelemetrytypesSignalDTO.logs) {
|
||||
return sanitizeSelectFields(
|
||||
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
}
|
||||
if (signal === TelemetrytypesSignalDTO.traces) {
|
||||
return sanitizeSelectFields(
|
||||
defaultTraceSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
defaultColumnsForSignal,
|
||||
readSelectFields,
|
||||
writeSelectFields,
|
||||
} from '../ListColumnsEditor/selectFields';
|
||||
|
||||
interface UseSwitchColumnsOnSignalChangeArgs {
|
||||
/** Gate so the switch only runs for the List kind (the only one with columns). */
|
||||
@@ -42,11 +22,10 @@ interface UseSwitchColumnsOnSignalChangeArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the List panel's chosen columns to the new datasource's defaults when
|
||||
* the panel's telemetry signal changes (e.g. logs → traces). V1 kept a separate
|
||||
* field list per datasource; V2 stores a single `selectFields`, so columns picked
|
||||
* for one signal are meaningless after switching — replace them with the new
|
||||
* source's sensible defaults (matching V1's logs/traces list defaults).
|
||||
* Swaps the List panel's columns when the telemetry signal changes. V2 stores a
|
||||
* single `selectFields`, so each signal's columns are stashed and restored on
|
||||
* switch-back; a signal seen for the first time gets the datasource defaults (V1
|
||||
* parity).
|
||||
*/
|
||||
export function useSwitchColumnsOnSignalChange({
|
||||
enabled,
|
||||
@@ -55,28 +34,29 @@ export function useSwitchColumnsOnSignalChange({
|
||||
onChangeSpec,
|
||||
}: UseSwitchColumnsOnSignalChangeArgs): void {
|
||||
const prevSignalRef = useRef(signal);
|
||||
const columnsBySignalRef = useRef<
|
||||
Map<string, TelemetrytypesTelemetryFieldKeyDTO[]>
|
||||
>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevSignalRef.current;
|
||||
|
||||
if (!enabled || !signal) {
|
||||
return;
|
||||
}
|
||||
// Track only real signals: a transient `undefined` (mid query-edit) must
|
||||
// not become `prev`, or stash/restore would lose a step.
|
||||
prevSignalRef.current = signal;
|
||||
|
||||
if (!enabled) {
|
||||
if (!prev || prev === signal) {
|
||||
return;
|
||||
}
|
||||
// Only an actual switch between two known signals swaps the columns;
|
||||
// transient `undefined` states (mid query-edit) leave the selection intact.
|
||||
if (!prev || !signal || prev === signal) {
|
||||
return;
|
||||
}
|
||||
onChangeSpec({
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
spec: {
|
||||
...spec.plugin.spec,
|
||||
selectFields: defaultColumnsForSignal(signal),
|
||||
},
|
||||
},
|
||||
} as DashboardtypesPanelSpecDTO);
|
||||
|
||||
// Stash the leaving signal's columns; restore the entering one's, or its
|
||||
// datasource defaults the first time it's seen.
|
||||
columnsBySignalRef.current.set(prev, readSelectFields(spec));
|
||||
const restored =
|
||||
columnsBySignalRef.current.get(signal) ?? defaultColumnsForSignal(signal);
|
||||
onChangeSpec(writeSelectFields(spec, restored));
|
||||
}, [enabled, signal, spec, onChangeSpec]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type {
|
||||
import {
|
||||
DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -29,6 +29,8 @@ import { usePanelQuery } from '../hooks/usePanelQuery';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useTableColumns } from './hooks/useTableColumns';
|
||||
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
|
||||
@@ -39,6 +41,10 @@ interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Creating a new panel (seeded default) vs editing an existing one. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
@@ -46,19 +52,26 @@ interface PanelEditorContainerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 panel editor page body (rendered full-page by `PanelEditorPage`): a resizable
|
||||
* split with the live preview + query builder on the left and the config pane on the
|
||||
* right. Owns the draft state and the save round-trip.
|
||||
* V2 panel editor page body: a resizable split with the live preview + query
|
||||
* builder on the left and the config pane on the right. Owns the draft state and
|
||||
* the save round-trip.
|
||||
*/
|
||||
function PanelEditorContainer({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
|
||||
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
|
||||
const { save, isSaving } = usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew,
|
||||
layoutIndex,
|
||||
});
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: 'panel-editor-v2',
|
||||
storage: layoutStorage,
|
||||
@@ -79,7 +92,7 @@ function PanelEditorContainer({
|
||||
PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
// One shared query result for the whole editor; the preview renders it.
|
||||
const panelDef = getPanelDefinition(draft.spec.plugin.kind);
|
||||
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -91,23 +104,33 @@ function PanelEditorContainer({
|
||||
} = usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
enabled: !!panelDef,
|
||||
enabled: !!panelDefinition,
|
||||
});
|
||||
|
||||
// Seed the shared query builder from the draft and expose the Stage-&-Run action.
|
||||
// A new panel defaults to its kind's first supported signal (e.g. List → logs);
|
||||
// drives both the seed query's datasource and the seed of its default columns.
|
||||
const defaultDataSource = useMemo(
|
||||
() => panelDefinition?.supportedSignals[0] || TelemetrytypesSignalDTO.metrics,
|
||||
[panelDefinition],
|
||||
);
|
||||
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
// New panel's seed query is the builder default, not a real saved query —
|
||||
// always serialize it on save.
|
||||
alwaysSerializeQuery: isNew,
|
||||
defaultDataSource,
|
||||
});
|
||||
|
||||
// Switch the panel's visualization kind in place (reversible per session).
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
|
||||
|
||||
// Spec and query dirtiness are tracked independently so query re-serialization
|
||||
// never false-dirties.
|
||||
const isDirty = isSpecDirty || isQueryDirty;
|
||||
// The List panel edits its columns below the query builder (V1 parity), so the
|
||||
// editor container resolves the committed query's signal once and shares it
|
||||
// with both the columns control and the datasource-switch effect below.
|
||||
// never false-dirties. A new panel is always savable (you're creating it).
|
||||
const isDirty = isNew || isSpecDirty || isQueryDirty;
|
||||
const isListPanel = fullKind === 'signoz/ListPanel';
|
||||
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
|
||||
// values; cast at this boundary (as ConfigPane does) so the columns editor's
|
||||
@@ -116,10 +139,8 @@ function PanelEditorContainer({
|
||||
| TelemetrytypesSignalDTO
|
||||
| undefined;
|
||||
|
||||
// When the List panel's datasource changes, swap its columns to the new
|
||||
// source's defaults (V1 kept a per-datasource field list; V2 has one
|
||||
// `selectFields`). Driven by the committed query's signal, so it lives in the
|
||||
// editor container alongside the query sync — ConfigPane stays presentational.
|
||||
// Swap the List panel's columns to the new source's defaults on a datasource
|
||||
// change (V1 kept a per-datasource field list; V2 has one `selectFields`).
|
||||
useSwitchColumnsOnSignalChange({
|
||||
enabled: isListPanel,
|
||||
signal: listSignal,
|
||||
@@ -127,6 +148,14 @@ function PanelEditorContainer({
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Seed a new List panel's default columns so the Columns control isn't empty.
|
||||
useSeedNewListColumns({
|
||||
enabled: isNew && isListPanel,
|
||||
signal: defaultDataSource,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
|
||||
const { onDragSelect } = usePanelInteractions();
|
||||
const legendSeries = useLegendSeries(draft, data);
|
||||
@@ -166,17 +195,20 @@ function PanelEditorContainer({
|
||||
onLayoutChanged={onMainLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDef={panelDef}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
/>
|
||||
{panelDefinition && (
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isLoading={isLoading || isFetching}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
/>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
@@ -210,6 +242,7 @@ function PanelEditorContainer({
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../Panels/types/panelKind';
|
||||
|
||||
// New (unsaved) panels share a fixed id segment, carrying kind + target section
|
||||
// in the query: `/panel/new?panelKind=signoz/ListPanel&layoutIndex=2`. The real
|
||||
// id is generated on save.
|
||||
export const NEW_PANEL_ID = 'new';
|
||||
const PANEL_KIND_PARAM = 'panelKind';
|
||||
const LAYOUT_INDEX_PARAM = 'layoutIndex';
|
||||
|
||||
/** Query string (incl. leading `?`) for the new-panel editor route. */
|
||||
export function newPanelSearch(
|
||||
pluginKind: PanelKind,
|
||||
layoutIndex?: number,
|
||||
): string {
|
||||
const params = new URLSearchParams({ [PANEL_KIND_PARAM]: pluginKind });
|
||||
if (layoutIndex !== undefined) {
|
||||
params.set(LAYOUT_INDEX_PARAM, String(layoutIndex));
|
||||
}
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The PanelKind a `panel/new` route is creating, or null when the id isn't the
|
||||
* new-panel sentinel or the `panelKind` param is missing/unknown (stale link).
|
||||
*/
|
||||
export function parseNewPanelKind(
|
||||
panelId: string,
|
||||
search: string,
|
||||
): PanelKind | null {
|
||||
if (panelId !== NEW_PANEL_ID) {
|
||||
return null;
|
||||
}
|
||||
const kind = new URLSearchParams(search).get(PANEL_KIND_PARAM);
|
||||
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
|
||||
}
|
||||
|
||||
/** Target section index for a new panel, or undefined when unset/invalid. */
|
||||
export function parseNewPanelLayoutIndex(search: string): number | undefined {
|
||||
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);
|
||||
if (raw === null || raw === '') {
|
||||
return undefined;
|
||||
}
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.noData {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.noDataText {
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -1,27 +1,39 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Clock, RotateCw } from '@signozhq/icons';
|
||||
|
||||
import styles from './NoData.module.scss';
|
||||
import PanelMessage from '../PanelMessage/PanelMessage';
|
||||
|
||||
interface NoDataProps {
|
||||
/** Message to display. Defaults to "No data". */
|
||||
label?: string;
|
||||
/** Title override. Defaults to the time-range empty-state copy. */
|
||||
title?: string;
|
||||
/** Description override. Defaults to the "widen the range" hint. */
|
||||
description?: string;
|
||||
/** When provided, renders a Retry button that re-runs the query. */
|
||||
onRetry?: () => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared empty-state for panel renderers, shown when a query resolves but
|
||||
* returns nothing to plot. Centred in the panel body so every panel kind
|
||||
* surfaces the same "No data" affordance instead of each renderer (or its
|
||||
* underlying chart) inventing its own copy and casing.
|
||||
* Shared empty-state for panel renderers: wraps `PanelMessage` so every panel
|
||||
* kind surfaces the same "no data" affordance when a query returns nothing.
|
||||
*/
|
||||
function NoData({
|
||||
label = 'No data',
|
||||
title = 'No data in this time range',
|
||||
description = 'Nothing in the selected window. Try widening the range.',
|
||||
onRetry,
|
||||
'data-testid': testId = 'panel-no-data',
|
||||
}: NoDataProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.noData} data-testid={testId}>
|
||||
<Typography.Text className={styles.noDataText}>{label}</Typography.Text>
|
||||
</div>
|
||||
<PanelMessage
|
||||
icon={<Clock size={18} />}
|
||||
title={title}
|
||||
description={description}
|
||||
action={
|
||||
onRetry
|
||||
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
|
||||
: undefined
|
||||
}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Centred, vertically-stacked panel state (no query / no data / error). Fills
|
||||
// the panel body below the header and centres its content both axes.
|
||||
.message {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Muted glyph in a soft tinted disc so the icon reads as decorative chrome
|
||||
// rather than an actionable control.
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
color: var(--l2-foreground);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.iconDanger {
|
||||
color: var(--bg-cherry-500);
|
||||
background: var(--bg-cherry-500-transparent, rgba(231, 64, 64, 0.12));
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
max-width: 280px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './PanelMessage.module.scss';
|
||||
|
||||
export interface PanelMessageAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
/** Optional leading icon for the action button. */
|
||||
icon?: ReactElement;
|
||||
}
|
||||
|
||||
interface PanelMessageProps {
|
||||
/** Glyph shown above the title — sets the state's visual identity. */
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
/** Secondary line explaining the state / suggesting a next step. */
|
||||
description?: string;
|
||||
/** Optional call-to-action (e.g. Retry). Omitted → no button. */
|
||||
action?: PanelMessageAction;
|
||||
/** `danger` tints the icon for failure states; `neutral` for empty states. */
|
||||
tone?: 'neutral' | 'danger';
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared centred panel state (icon + title + optional description/action) so the
|
||||
* no-query / no-data / error states stay visually consistent across call sites.
|
||||
*/
|
||||
function PanelMessage({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
tone = 'neutral',
|
||||
'data-testid': testId,
|
||||
}: PanelMessageProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.message} data-testid={testId}>
|
||||
<div className={cx(styles.icon, { [styles.iconDanger]: tone === 'danger' })}>
|
||||
{icon}
|
||||
</div>
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
{description && (
|
||||
<Typography.Text className={styles.description}>
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{action && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={action.icon}
|
||||
onClick={action.onClick}
|
||||
className={styles.action}
|
||||
data-testid={testId ? `${testId}-action` : undefined}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelMessage;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import PanelMessage from '../PanelMessage';
|
||||
|
||||
describe('PanelMessage', () => {
|
||||
it('renders the icon, title and description', () => {
|
||||
render(
|
||||
<PanelMessage
|
||||
icon={<svg data-testid="icon" />}
|
||||
title="Nothing to visualize yet"
|
||||
description="This panel has no query."
|
||||
data-testid="panel-state"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-state')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nothing to visualize yet')).toBeInTheDocument();
|
||||
expect(screen.getByText('This panel has no query.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no action button when no action is provided', () => {
|
||||
render(
|
||||
<PanelMessage icon={null} title="No data" data-testid="panel-state" />,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('panel-state-action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the action button and fires onClick when pressed', () => {
|
||||
const onClick = jest.fn();
|
||||
render(
|
||||
<PanelMessage
|
||||
icon={null}
|
||||
title="Couldn’t load panel data"
|
||||
action={{ label: 'Retry', onClick }}
|
||||
data-testid="panel-error"
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('panel-error-action');
|
||||
expect(button).toHaveTextContent('Retry');
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ function BarPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
@@ -42,9 +43,8 @@ function BarPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesBarChartPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -138,7 +138,7 @@ function BarPanelRenderer({
|
||||
data-testid="bar-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
kind: 'signoz/BarChartPanel',
|
||||
displayName: 'Bar Chart',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { SectionConfig } from '../../types/sections';
|
||||
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
|
||||
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true, stacking: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
|
||||
@@ -26,6 +26,7 @@ function HistogramPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
panelMode,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
@@ -34,9 +35,8 @@ function HistogramPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -113,7 +113,7 @@ function HistogramPanelRenderer({
|
||||
data-testid="histogram-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
kind: 'signoz/HistogramPanel',
|
||||
displayName: 'Histogram',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true },
|
||||
},
|
||||
{
|
||||
kind: 'legend',
|
||||
controls: { position: true },
|
||||
|
||||
@@ -26,14 +26,14 @@ import { useListRowInteraction } from './useListRowInteraction';
|
||||
|
||||
import styles from './ListPanel.module.scss';
|
||||
|
||||
// `body` flexes to fill the remaining table width (module-level so the resize
|
||||
// hook's memo dependency stays referentially stable across renders).
|
||||
// `body` flexes to fill remaining width; module-level to stay referentially stable for the resize hook's memo.
|
||||
const BODY_FLEX_COLUMNS = ['body'];
|
||||
|
||||
function ListPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
pagination,
|
||||
}: PanelRendererProps<'signoz/ListPanel'>): JSX.Element {
|
||||
@@ -42,16 +42,14 @@ function ListPanelRenderer({
|
||||
const { height } = useResizeObserver(containerRef);
|
||||
const { scrollY } = useMemo(() => computeTableLayout(height), [height]);
|
||||
|
||||
// The registry guarantees this Renderer only runs for `signoz/ListPanel`, so
|
||||
// the cast is a documented boundary narrowing.
|
||||
// `panel` is narrowed to this kind by PanelRendererProps, so no cast needed.
|
||||
const spec = useMemo<DashboardtypesListPanelSpecDTO>(
|
||||
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesListPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
// Telemetry signal of the panel's first builder query — drives data flattening,
|
||||
// per-signal cell rendering, and the row-click behavior (log drawer vs trace
|
||||
// navigation). Cast at this boundary (the query carries the same string values).
|
||||
// Telemetry signal of the first builder query; drives flattening, cell rendering,
|
||||
// and row-click behavior. Cast is safe — the query carries the same string values.
|
||||
const signal = useMemo(
|
||||
() =>
|
||||
(getBuilderQueries(panel.spec.queries)[0]
|
||||
@@ -83,7 +81,7 @@ function ListPanelRenderer({
|
||||
[table, signal, formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
// User-resizable columns, persisted per panel; `body` flexes to fill width.
|
||||
// User-resizable columns, persisted per panel.
|
||||
const { columns: resizableColumns, components } = useResizableColumns({
|
||||
panelId,
|
||||
columns,
|
||||
@@ -92,8 +90,7 @@ function ListPanelRenderer({
|
||||
|
||||
const dataSource = useMemo(() => table?.rows ?? [], [table]);
|
||||
|
||||
// Header search filters the current page client-side (V1 parity); paging
|
||||
// across pages is server-side via `pagination`.
|
||||
// Header search filters the current page client-side (V1 parity); cross-page paging is server-side via `pagination`.
|
||||
const filteredDataSource = useMemo(
|
||||
() => filterTableRows(dataSource, searchTerm),
|
||||
[dataSource, searchTerm],
|
||||
@@ -115,8 +112,7 @@ function ListPanelRenderer({
|
||||
[spec.selectFields],
|
||||
);
|
||||
|
||||
// Show the footer whenever the panel pages server-side (no explicit query
|
||||
// limit), so the page-size picker is always reachable — V1 parity.
|
||||
// Show the footer whenever the panel pages server-side, so the page-size picker stays reachable (V1 parity).
|
||||
const showPager = !!pagination;
|
||||
|
||||
return (
|
||||
@@ -126,7 +122,7 @@ function ListPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
@@ -142,9 +138,7 @@ function ListPanelRenderer({
|
||||
components={components}
|
||||
dataSource={filteredDataSource}
|
||||
pagination={false}
|
||||
// Scroll the body vertically only — no `x: 'max-content'`, which
|
||||
// forced a content-width min and pushed columns off-screen;
|
||||
// `tableLayout="fixed"` fits them to the available width.
|
||||
// Vertical scroll only; `x: 'max-content'` forced a content-width min that pushed columns off-screen.
|
||||
scroll={{ y: scrollY }}
|
||||
onRow={onRow}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
type DashboardtypesListPanelSpecDTO,
|
||||
type DashboardtypesPanelDTO,
|
||||
type QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
@@ -10,16 +9,19 @@ import type {
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { fireEvent, render } from 'tests/test-utils';
|
||||
|
||||
import { BaseRendererProps } from '../../../types/rendererProps';
|
||||
import type {
|
||||
PanelOfKind,
|
||||
PanelRendererProps,
|
||||
} from '../../../types/rendererProps';
|
||||
import ListPanelRenderer from '../Renderer';
|
||||
|
||||
function panelWith(
|
||||
spec: DashboardtypesListPanelSpecDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
): PanelOfKind<'signoz/ListPanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/ListPanel', spec } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
} as unknown as PanelOfKind<'signoz/ListPanel'>;
|
||||
}
|
||||
|
||||
// V5 raw response: one result carrying flattened log rows.
|
||||
@@ -49,9 +51,9 @@ const emptyData: PanelQueryData = {
|
||||
};
|
||||
|
||||
function renderPanel(
|
||||
props: Partial<BaseRendererProps>,
|
||||
props: Partial<PanelRendererProps<'signoz/ListPanel'>>,
|
||||
): ReturnType<typeof render> {
|
||||
const baseProps: BaseRendererProps = {
|
||||
const baseProps: PanelRendererProps<'signoz/ListPanel'> = {
|
||||
panelId: 'panel-1',
|
||||
panel: panelWith({}),
|
||||
data: emptyData,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
kind: 'signoz/ListPanel',
|
||||
displayName: 'List',
|
||||
Renderer,
|
||||
// Raw records come from logs and traces; metrics don't produce row data.
|
||||
supportedSignals: [DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
sections,
|
||||
actions: {
|
||||
view: true,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// List columns are edited below the query builder, not in the config pane, so
|
||||
// only Context Links shows here.
|
||||
export const sections: SectionConfig[] = [{ kind: 'contextLinks' }];
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -16,10 +16,10 @@ import ValueDisplay from './components/ValueDisplay/ValueDisplay';
|
||||
function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesNumberPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ function NumberPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{value === null ? (
|
||||
<NoData data-testid="number-panel-no-data" />
|
||||
<NoData data-testid="number-panel-no-data" onRetry={refetch} />
|
||||
) : (
|
||||
<ValueDisplay
|
||||
value={formattedValue}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
kind: 'signoz/NumberPanel',
|
||||
displayName: 'Number',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'comparison' } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -20,13 +20,13 @@ function PiePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesPieChartPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -70,7 +70,7 @@ function PiePanelRenderer({
|
||||
return (
|
||||
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
|
||||
{slices.length === 0 ? (
|
||||
<NoData />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<Pie
|
||||
data={slices}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
kind: 'signoz/PieChartPanel',
|
||||
displayName: 'Pie Chart',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { SectionConfig } from '../../types/sections';
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
|
||||
// Legend `colors` is omitted: the pie legend is always interactive swatches.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -25,10 +25,10 @@ function TablePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
}: PanelRendererProps<'signoz/TablePanel'>): JSX.Element {
|
||||
// Measure the panel so each page roughly fills it (min 10 rows) and the
|
||||
// header stays pinned while the body scrolls.
|
||||
// Measure the panel so each page roughly fills it (min 10 rows) with a pinned header.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { height } = useResizeObserver(containerRef);
|
||||
const { pageSize, scrollY } = useMemo(
|
||||
@@ -36,12 +36,9 @@ function TablePanelRenderer({
|
||||
[height],
|
||||
);
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/TablePanel'`, so the cast is a
|
||||
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
|
||||
// produce a fresh object on each render.
|
||||
// `panel` is narrowed to this kind by PanelRendererProps, so no cast needed.
|
||||
const spec = useMemo<DashboardtypesTablePanelSpecDTO>(
|
||||
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTablePanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -92,15 +89,13 @@ function TablePanelRenderer({
|
||||
[table],
|
||||
);
|
||||
|
||||
// Header search filters rows client-side (V1 parity). Falls back to the full
|
||||
// set when the term is empty, so non-searching tables pay nothing.
|
||||
// Header search filters rows client-side (V1 parity); empty term returns the full set, so non-searching tables pay nothing.
|
||||
const filteredDataSource = useMemo(
|
||||
() => filterTableRows(dataSource, searchTerm),
|
||||
[dataSource, searchTerm],
|
||||
);
|
||||
|
||||
// Keep pagination in range as the filtered set shrinks: a new term snaps back
|
||||
// to the first page so the user never lands on a now-empty page.
|
||||
// Snap back to page 1 on a new search term so the filtered set never lands on a now-empty page.
|
||||
const [page, setPage] = useState(1);
|
||||
useEffect(() => setPage(1), [searchTerm]);
|
||||
|
||||
@@ -111,7 +106,7 @@ function TablePanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<Table
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
type DashboardtypesTablePanelSpecDTO,
|
||||
type QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -7,16 +6,19 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { BaseRendererProps } from '../../../types/rendererProps';
|
||||
import type {
|
||||
PanelOfKind,
|
||||
PanelRendererProps,
|
||||
} from '../../../types/rendererProps';
|
||||
import TablePanelRenderer from '../Renderer';
|
||||
|
||||
function panelWith(
|
||||
spec: DashboardtypesTablePanelSpecDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
): PanelOfKind<'signoz/TablePanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/TablePanel', spec } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
} as unknown as PanelOfKind<'signoz/TablePanel'>;
|
||||
}
|
||||
|
||||
// V5 scalar response: one joined result with a group column + an aggregation column.
|
||||
@@ -60,9 +62,9 @@ const emptyData: PanelQueryData = {
|
||||
};
|
||||
|
||||
function renderPanel(
|
||||
props: Partial<BaseRendererProps>,
|
||||
props: Partial<PanelRendererProps<'signoz/TablePanel'>>,
|
||||
): ReturnType<typeof render> {
|
||||
const baseProps: BaseRendererProps = {
|
||||
const baseProps: PanelRendererProps<'signoz/TablePanel'> = {
|
||||
panelId: 'panel-1',
|
||||
panel: panelWith({}),
|
||||
data: emptyData,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
kind: 'signoz/TablePanel',
|
||||
displayName: 'Table',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
|
||||
actions: {
|
||||
view: true,
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { SectionConfig } from '../../types/sections';
|
||||
// single column set). It exposes the per-panel time scope, formatting (decimals +
|
||||
// per-column units), per-column thresholds, and context links.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { decimals: true, columnUnits: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'table' } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -32,6 +32,7 @@ function TimeSeriesPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
@@ -42,10 +43,8 @@ function TimeSeriesPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
// Memoized so the `?? {}` fallback doesn't produce a fresh object each render.
|
||||
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
|
||||
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -140,7 +139,7 @@ function TimeSeriesPanelRenderer({
|
||||
data-testid="time-series-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
displayName: 'Time Series',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, fillSpans: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, colors: true } },
|
||||
|
||||
@@ -11,9 +11,7 @@ import type {
|
||||
} from './types/panelDefinition';
|
||||
import { PanelKind } from './types/panelKind';
|
||||
|
||||
// Pure assembly: each kind owns its own PanelDefinition (see
|
||||
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a
|
||||
// single entry below — no other central file needs editing.
|
||||
// Each kind owns its PanelDefinition; registering a new panel is one entry here.
|
||||
export const PANELS: PanelRegistry = {
|
||||
[TimeSeries.kind]: TimeSeries,
|
||||
[BarChart.kind]: BarChart,
|
||||
@@ -27,12 +25,7 @@ export const PANELS: PanelRegistry = {
|
||||
export function getPanelDefinition(
|
||||
kind: PanelKind,
|
||||
): RenderablePanelDefinition | undefined {
|
||||
if (!kind) {
|
||||
return undefined;
|
||||
}
|
||||
// The registry is correlated by kind, so a string lookup yields a union over
|
||||
// every kind's exactly-typed definition. The renderer cannot be validated
|
||||
// against that union at the JSX boundary, so widen to the kind-agnostic
|
||||
// surface here — the single, intentional cast for the whole panel system.
|
||||
return PANELS[kind] as unknown as RenderablePanelDefinition | undefined;
|
||||
// Single intentional cast widening the per-kind Renderer to the kind-agnostic
|
||||
// prop surface (a per-kind renderer can't be statically validated against the union).
|
||||
return PANELS[kind] as RenderablePanelDefinition | undefined;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { SectionConfig } from './sections';
|
||||
import type { AnyPanelInteractionProps } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Which panel actions a kind supports. Required field, so registering a new
|
||||
@@ -35,7 +35,7 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
displayName: string;
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
sections: SectionConfig[];
|
||||
supportedSignals: DataSource[];
|
||||
supportedSignals: TelemetrytypesSignalDTO[];
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelPluginDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
@@ -22,37 +26,59 @@ export interface DashboardPreference {
|
||||
dashboardId?: string;
|
||||
}
|
||||
|
||||
// Kind-agnostic props every renderer receives. Kind-specific interaction props
|
||||
// are layered on per-kind by PanelRendererProps<K>.
|
||||
/** Kind-agnostic props every renderer receives; kind-specific interactions are layered on by PanelRendererProps<K>. */
|
||||
export interface BaseRendererProps {
|
||||
panelId: string;
|
||||
/**
|
||||
* The whole perses panel — renderers derive `spec` and `queries` from this.
|
||||
* Required: the render boundary only mounts a renderer once the panel and its
|
||||
* kind are resolved, so a renderer never sees an absent panel.
|
||||
*/
|
||||
/** The whole panel — renderers derive `spec` and `queries` from it. Required: the render boundary only mounts once panel + kind resolve. */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Raw V5 fetch result — response + the request that produced it. */
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Re-run the panel query; wired to the no-data Retry affordance. Optional so standalone call sites (e.g. the editor preview) can omit it. */
|
||||
refetch?: () => void;
|
||||
/** Gate for the drill-down right-click menu. Off by default in V2. */
|
||||
enableDrillDown?: boolean;
|
||||
/** Render context (dashboard widget vs. standalone vs. editor); see PanelMode. */
|
||||
panelMode: PanelMode;
|
||||
/** Dashboard-level preferences propagated to every panel; shell resolves, renderer consumes. */
|
||||
dashboardPreference?: DashboardPreference;
|
||||
/**
|
||||
* Free-text filter from the header search box, applied client-side. Only
|
||||
* meaningful for kinds that declare `actions.search`; others ignore it.
|
||||
*/
|
||||
/** Free-text header filter, applied client-side. Only meaningful for kinds that declare `actions.search`. */
|
||||
searchTerm?: string;
|
||||
/** Server-side paging handles. Present only for raw/list panels; others ignore it. */
|
||||
pagination?: PanelPagination;
|
||||
}
|
||||
|
||||
// Renderer props for a specific kind: shared base plus that kind's interaction
|
||||
// surface. Indexing PanelInteractionMap forces it to cover every PanelKind; the
|
||||
// default K = PanelKind yields the widest surface (a union over all kinds).
|
||||
export type PanelRendererProps<K extends PanelKind = PanelKind> =
|
||||
BaseRendererProps & PanelInteractionMap[K];
|
||||
// The single plugin variant for kind K, picked from the generated plugin union.
|
||||
// Distributes over the union, coercing each member's nominal kind-enum to its
|
||||
// string value (`${VK & string}`) to match K. K = PanelKind recovers the full union.
|
||||
type PluginOfKind<K extends PanelKind> =
|
||||
DashboardtypesPanelPluginDTO extends infer V
|
||||
? V extends { kind: infer VK }
|
||||
? `${VK & string}` extends K
|
||||
? V
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
// The panel narrowed to kind K: the wire DTO with `plugin` (and `plugin.spec`)
|
||||
// fixed to K's single variant, so a renderer reads `panel.spec.plugin.spec` as
|
||||
// its own spec type with no cast.
|
||||
export type PanelOfKind<K extends PanelKind = PanelKind> = Omit<
|
||||
DashboardtypesPanelDTO,
|
||||
'spec'
|
||||
> & {
|
||||
spec: Omit<DashboardtypesPanelSpecDTO, 'plugin'> & {
|
||||
plugin: PluginOfKind<K>;
|
||||
};
|
||||
};
|
||||
|
||||
// Renderer props for kind K: the base (with `panel` narrowed to K) plus K's
|
||||
// interaction surface (PanelInteractionMap[K]), so a renderer sees its exact spec
|
||||
// and only the gestures it supports. The default K = PanelKind is the widest surface.
|
||||
export type PanelRendererProps<K extends PanelKind = PanelKind> = Omit<
|
||||
BaseRendererProps,
|
||||
'panel'
|
||||
> & {
|
||||
panel: PanelOfKind<K>;
|
||||
} & PanelInteractionMap[K];
|
||||
|
||||
@@ -89,8 +89,11 @@ export interface SectionControls {
|
||||
spanGaps?: boolean;
|
||||
};
|
||||
buckets: { count?: boolean; width?: boolean; mergeQueries?: boolean };
|
||||
// stacking → stackedBarChart (Bar); fillSpans → fill gaps with 0 (TimeSeries).
|
||||
// switchPanelKind → the visualization-type switcher (every kind, so you can switch
|
||||
// away from any panel); stacking → stackedBarChart (Bar); fillSpans → fill gaps with
|
||||
// 0 (TimeSeries).
|
||||
visualization: {
|
||||
switchPanelKind: boolean;
|
||||
timePreference?: boolean;
|
||||
stacking?: boolean;
|
||||
fillSpans?: boolean;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { sections as barSections } from '../../kinds/BarChartPanel/sections';
|
||||
import { sections as histogramSections } from '../../kinds/HistogramPanel/sections';
|
||||
import { sections as listSections } from '../../kinds/ListPanel/sections';
|
||||
import { sections as timeSeriesSections } from '../../kinds/TimeSeriesPanel/sections';
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
import { buildDefaultPluginSpec } from '../buildDefaultPluginSpec';
|
||||
|
||||
describe('buildDefaultPluginSpec', () => {
|
||||
it('seeds the TimeSeries dropdowns/segmented controls with their renderer defaults', () => {
|
||||
expect(buildDefaultPluginSpec(timeSeriesSections)).toStrictEqual({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
},
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
chartAppearance: {
|
||||
lineStyle: DashboardtypesLineStyleDTO.solid,
|
||||
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
|
||||
fillMode: DashboardtypesFillModeDTO.none,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('omits chartAppearance for a kind that does not declare it (Bar)', () => {
|
||||
expect(buildDefaultPluginSpec(barSections)).toStrictEqual({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
},
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds only the legend for Histogram (no visualization section)', () => {
|
||||
expect(buildDefaultPluginSpec(histogramSections)).toStrictEqual({
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty spec for a kind with no seeded controls (List)', () => {
|
||||
expect(buildDefaultPluginSpec(listSections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('does not seed controls that already show a clear default', () => {
|
||||
// `axes` and `formatting` stay unset — their empty state is the chart default.
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'label' } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('only seeds the legend position when the kind exposes that control', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: 'legend', controls: { colors: true } },
|
||||
];
|
||||
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { buildDefaultQueries } from '../buildDefaultQueries';
|
||||
|
||||
describe('buildDefaultQueries', () => {
|
||||
it('seeds a List panel with a runnable logs query ordered by timestamp desc', () => {
|
||||
const queries = buildDefaultQueries('signoz/ListPanel');
|
||||
|
||||
expect(queries).toHaveLength(1);
|
||||
// orderBy timestamp desc must survive serialization so the preview opens
|
||||
// pre-sorted (V1 parity).
|
||||
const serialized = JSON.stringify(queries);
|
||||
expect(serialized).toContain('timestamp');
|
||||
expect(serialized).toContain('desc');
|
||||
expect(serialized.toLowerCase()).toContain('logs');
|
||||
});
|
||||
|
||||
it('seeds no query for non-List kinds (they seed from the builder)', () => {
|
||||
expect(buildDefaultQueries('signoz/TimeSeriesPanel')).toStrictEqual([]);
|
||||
expect(buildDefaultQueries('signoz/NumberPanel')).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { getPanelQueryType } from '../getPanelQueryType';
|
||||
|
||||
function panelWithEnvelopes(envelopes: unknown[]): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'P' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: envelopes.length
|
||||
? [
|
||||
{
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('getPanelQueryType', () => {
|
||||
it('returns undefined when the panel has no query', () => {
|
||||
expect(getPanelQueryType(panelWithEnvelopes([]))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports the builder mode for builder queries', () => {
|
||||
const panel = panelWithEnvelopes([
|
||||
{ type: 'builder_query', spec: { signal: 'traces', name: 'A' } },
|
||||
]);
|
||||
expect(getPanelQueryType(panel)).toBe(EQueryType.QUERY_BUILDER);
|
||||
});
|
||||
|
||||
it('reports PromQL when a promql envelope is present', () => {
|
||||
const panel = panelWithEnvelopes([
|
||||
{ type: 'promql', spec: { query: 'up', name: 'A' } },
|
||||
]);
|
||||
expect(getPanelQueryType(panel)).toBe(EQueryType.PROM);
|
||||
});
|
||||
|
||||
it('reports ClickHouse when a clickhouse_sql envelope is present', () => {
|
||||
const panel = panelWithEnvelopes([
|
||||
{ type: 'clickhouse_sql', spec: { query: 'SELECT 1', name: 'A' } },
|
||||
]);
|
||||
expect(getPanelQueryType(panel)).toBe(EQueryType.CLICKHOUSE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SectionConfig, SectionSpecMap } from '../types/sections';
|
||||
|
||||
/**
|
||||
* Seeded plugin-spec slices, typed as canonical section slices so each value is
|
||||
* checked against its DTO. A partial cross-section, not any single kind's spec,
|
||||
* so the union cast stays localized to `createDefaultPanel`.
|
||||
*/
|
||||
export interface DefaultPluginSpec {
|
||||
visualization?: SectionSpecMap['visualization'];
|
||||
legend?: SectionSpecMap['legend'];
|
||||
chartAppearance?: SectionSpecMap['chartAppearance'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds per-kind config defaults derived from the kind's declared `sections` so the
|
||||
* config pane opens populated. Values equal the renderer fallbacks (display only).
|
||||
* Controls whose empty state already IS the default are left unset.
|
||||
*/
|
||||
export function buildDefaultPluginSpec(
|
||||
sections: SectionConfig[],
|
||||
): DefaultPluginSpec {
|
||||
const spec: DefaultPluginSpec = {};
|
||||
|
||||
sections.forEach((section) => {
|
||||
switch (section.kind) {
|
||||
case 'visualization':
|
||||
if (section.controls.timePreference) {
|
||||
spec.visualization = {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case 'legend':
|
||||
if (section.controls.position) {
|
||||
spec.legend = { position: DashboardtypesLegendPositionDTO.bottom };
|
||||
}
|
||||
break;
|
||||
case 'chartAppearance': {
|
||||
const chartAppearance: SectionSpecMap['chartAppearance'] = {};
|
||||
if (section.controls.lineStyle) {
|
||||
chartAppearance.lineStyle = DashboardtypesLineStyleDTO.solid;
|
||||
}
|
||||
if (section.controls.lineInterpolation) {
|
||||
chartAppearance.lineInterpolation =
|
||||
DashboardtypesLineInterpolationDTO.spline;
|
||||
}
|
||||
if (section.controls.fillMode) {
|
||||
chartAppearance.fillMode = DashboardtypesFillModeDTO.none;
|
||||
}
|
||||
if (Object.keys(chartAppearance).length > 0) {
|
||||
spec.chartAppearance = chartAppearance;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return spec;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user