mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-30 20:00:44 +01:00
Compare commits
31 Commits
issue_5267
...
feat/noz-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f41dc64d65 | ||
|
|
934cf08774 | ||
|
|
fc83f91058 | ||
|
|
0d47f02100 | ||
|
|
810bf5d9a0 | ||
|
|
7d8a00ab8c | ||
|
|
348fca1b62 | ||
|
|
83e0e974fe | ||
|
|
10217274b8 | ||
|
|
7c3ac5b221 | ||
|
|
c1d40d7359 | ||
|
|
c5c1913f97 | ||
|
|
5ab6636863 | ||
|
|
3680cc7779 | ||
|
|
7cfedc46c4 | ||
|
|
69ba25d82e | ||
|
|
e42159f257 | ||
|
|
1e887dc9c9 | ||
|
|
7646aabb2b | ||
|
|
18851afb6d | ||
|
|
d5221a6ff3 | ||
|
|
19712c3579 | ||
|
|
e31683be11 | ||
|
|
1700ad06e6 | ||
|
|
ee3b45b80d | ||
|
|
4771e30c03 | ||
|
|
e933fa74c7 | ||
|
|
853397a79e | ||
|
|
bd526df11d | ||
|
|
8ac07d3d37 | ||
|
|
9bab8e0ae2 |
6
.github/workflows/e2eci.yaml
vendored
6
.github/workflows/e2eci.yaml
vendored
@@ -70,7 +70,11 @@ jobs:
|
||||
cd tests/e2e && pnpm install --frozen-lockfile
|
||||
- name: playwright-browsers
|
||||
run: |
|
||||
cd tests/e2e && pnpm playwright install --with-deps ${{ matrix.project }}
|
||||
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
|
||||
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
|
||||
docker rm pw
|
||||
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
|
||||
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
|
||||
- name: bring-up-stack
|
||||
run: |
|
||||
cd tests && \
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -38,7 +38,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite:
|
||||
- spanmapper
|
||||
- alerts
|
||||
- basepath
|
||||
- callbackauthn
|
||||
|
||||
11
.github/workflows/jsci.yaml
vendored
11
.github/workflows/jsci.yaml
vendored
@@ -56,17 +56,6 @@ jobs:
|
||||
PRIMUS_REF: main
|
||||
JS_SRC: frontend
|
||||
JS_PKG_MANAGER: pnpm
|
||||
languages:
|
||||
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: run
|
||||
run: bash frontend/scripts/validate-md-languages.sh
|
||||
openapi:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
|
||||
@@ -29,6 +29,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule/implmetricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
@@ -119,6 +121,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(_ sqlstore.SQLStore, _ dashboard.Module, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
return implcloudintegration.NewModule(), nil
|
||||
},
|
||||
func(_ sqlstore.SQLStore, _ telemetrystore.TelemetryStore, _ dashboard.Module, _ queryparser.QueryParser, _ licensing.Licensing, _ flagger.Flagger, _ telemetrytypes.MetadataStore, _ factory.ProviderSettings, _ int) metricreductionrule.Module {
|
||||
return implmetricreductionrule.NewModule()
|
||||
},
|
||||
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
|
||||
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, nil, nil))
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
|
||||
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
|
||||
eeimplmetricreductionrule "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule"
|
||||
eequerier "github.com/SigNoz/signoz/ee/querier"
|
||||
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
|
||||
eerules "github.com/SigNoz/signoz/ee/query-service/rules"
|
||||
@@ -46,6 +47,7 @@ import (
|
||||
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
@@ -182,6 +184,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
|
||||
},
|
||||
func(sqlStore sqlstore.SQLStore, ts telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, lic licensing.Licensing, flgr pkgflagger.Flagger, ms telemetrytypes.MetadataStore, ps factory.ProviderSettings, threads int) metricreductionrule.Module {
|
||||
return eeimplmetricreductionrule.NewModule(sqlStore, ts, dashboardModule, queryParser, lic, flgr, ms, ps, threads)
|
||||
},
|
||||
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
|
||||
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, eerules.PrepareTaskFunc, eerules.TestNotification))
|
||||
},
|
||||
|
||||
@@ -141,6 +141,10 @@ querier:
|
||||
flux_interval: 5m
|
||||
# The maximum number of concurrent queries for missing ranges.
|
||||
max_concurrent_queries: 4
|
||||
# When filtering logs by trace_id, clamp the query window to the trace time
|
||||
# range with padding to include slightly delayed log exports. Logs only; set
|
||||
# to 0 to disable.
|
||||
log_trace_id_window_padding: 5m
|
||||
|
||||
##################### TelemetryStore #####################
|
||||
telemetrystore:
|
||||
|
||||
@@ -1,48 +1,76 @@
|
||||
# Migrating from the install script to Foundry
|
||||
# Migrating from the install script and `deploy/` to Foundry
|
||||
|
||||
The install script (`install.sh`) and the bundled Compose and Swarm files
|
||||
under `deploy/` are deprecated in favor of [Foundry][foundry], the supported
|
||||
way to install and manage SigNoz. This guide moves an existing Docker Compose
|
||||
or Docker Swarm deployment to Foundry and reattaches your existing volumes, so
|
||||
your data is preserved.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The install script is now deprecated and will no longer receive updates.
|
||||
> This guide is only for **existing** `install.sh` / `deploy/` deployments.
|
||||
> Setting up SigNoz for the first time? Skip migration and install Foundry
|
||||
> directly: [SigNoz install docs][install-docs].
|
||||
|
||||
This guide walks you through migrating an existing SigNoz deployment running via
|
||||
Docker Compose to [Foundry](https://signoz.io/docs/install/docker/).
|
||||
## How it works
|
||||
|
||||
> [!NOTE]
|
||||
> Setting up SigNoz for the first time? You don't need this guide — follow the [SigNoz installation docs](https://signoz.io/docs/install/) instead.
|
||||
Foundry splits a deployment into two commands:
|
||||
|
||||
## Overview
|
||||
To stay up to date on new installation platforms and patterns, please refer to [Foundry](https://github.com/SigNoz/foundry).
|
||||
- `foundryctl forge` generates the deployment manifests from a `casting.yaml`.
|
||||
It never touches running containers, so it is safe to re-run while you
|
||||
iterate.
|
||||
- `foundryctl cast` applies those manifests: it (re)creates the containers and
|
||||
reuses the volumes you point it at.
|
||||
|
||||
Two `foundryctl` commands are used throughout this guide:
|
||||
- **`forge`** — generates deployment manifests from your `casting.yaml`. It does not touch running containers, so it is safe to re-run while you iterate.
|
||||
- **`cast`** — applies the generated manifests: it creates and starts the containers (and pulls new images).
|
||||
You write one `casting.yaml`, point a few patches at your existing data
|
||||
volumes, then cast. The steps below are the same for Compose and Swarm; they
|
||||
differ only in the casting (step 3) and how you stop the old stack (step 5).
|
||||
|
||||
## Prerequisites
|
||||
- [ ] Install Foundry - `curl -fsSL https://signoz.io/foundry.sh | bash`
|
||||
|
||||
## Migration Steps
|
||||
> [!WARNING]
|
||||
> **Before proceeding, back up both:**
|
||||
> - **Your docker volumes** — these hold your data.
|
||||
> - **Your existing `docker-compose.yaml` (and any config it references)** — keep a copy somewhere safe. The compose manifests are no longer distributed by SigNoz, so this backup is your only way to roll back to your previous setup.
|
||||
- An existing SigNoz deployment from `install.sh` or `deploy/` (Compose or
|
||||
Swarm).
|
||||
- `foundryctl` (installed in step 1).
|
||||
|
||||
1. Make a note of the volume names used by your existing deployment for the following components:
|
||||
- ClickHouse
|
||||
- SigNoz
|
||||
- ZooKeeper
|
||||
## Migrate
|
||||
|
||||
> If you used the docker compose file we provided, the volumes will be `signoz-clickhouse`, `signoz-sqlite`, and `signoz-zookeeper-1`.
|
||||
### 1. Install Foundry
|
||||
|
||||
2. Generate your `casting.yaml`. Based on internal testing, the following casting should generate the manifests that mimic the legacy docker compose setup (compare against your backed-up `docker-compose.yaml`). Once created, run `foundryctl forge -f casting.yaml`.
|
||||
```bash
|
||||
curl -fsSL https://signoz.io/foundry.sh | bash
|
||||
```
|
||||
|
||||
Foundry has a [Docker Compose example](https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose). Please use it as a reference.
|
||||
### 2. Keep your rollback path
|
||||
|
||||
> [!WARNING]
|
||||
> If your deployment had more than 1 shard or replica, you will need to adjust your manifest volumes accordingly.
|
||||
This migration reattaches your existing volumes in place; it does not move or
|
||||
delete your data. The only destructive action is passing `--volumes` / `-v`
|
||||
when you stop the old stack (step 5), so avoid that flag.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `replica` and `shard` macros below are placeholders. Replace them with the values from your existing ClickHouse configuration (check the `macros` section of your current ClickHouse config, e.g. `config.xml`/`metrika.xml`), otherwise the generated manifests will not match your existing data.
|
||||
> Keep a copy of your existing `docker-compose.yaml` / stack file (and any
|
||||
> config it references). SigNoz no longer distributes these files, so this copy
|
||||
> is your only way to roll back.
|
||||
|
||||
### 3. Write your `casting.yaml`
|
||||
|
||||
Use the casting for your deployment. Both reproduce the legacy single-node
|
||||
setup (ClickHouse + ZooKeeper + SQLite) and reattach your existing volumes;
|
||||
they differ only in `spec.deployment.flavor` and the volume-reuse patch
|
||||
(Compose volumes have a `name` to replace; Swarm volumes are bare, so the whole
|
||||
entry is replaced). If your deployment ran more than one shard or replica,
|
||||
adjust the volume patches accordingly. The
|
||||
[Docker Compose example][compose-example] is a useful reference.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `replica` and `shard` macros are placeholders. Replace them with the
|
||||
> values from your existing ClickHouse config (the `macros` section of
|
||||
> `config.xml` / `metrika.xml`), or the generated manifests will not match your
|
||||
> existing data.
|
||||
|
||||
<details>
|
||||
<summary><b>Docker Compose</b> casting.yaml</summary>
|
||||
|
||||
```yaml
|
||||
# casting.yaml
|
||||
apiVersion: v1alpha1
|
||||
kind: Installation
|
||||
metadata:
|
||||
@@ -61,8 +89,8 @@ spec:
|
||||
data:
|
||||
config-0-0.yaml: |
|
||||
macros:
|
||||
replica: "example01-01-1" # replace with your existing ClickHouse replica macro (see legacy configuration files for reference)
|
||||
shard: "01" # replace with your existing ClickHouse shard macro (see legacy configuration files for reference)
|
||||
replica: "example01-01-1" # replace with your replica macro
|
||||
shard: "01" # replace with your shard macro
|
||||
patches:
|
||||
- target: "deployment/compose.yaml"
|
||||
operations:
|
||||
@@ -80,50 +108,163 @@ spec:
|
||||
value: root
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The `user: root` patch on the ZooKeeper service is required so the container can read/write the data in your reused ZooKeeper volume, which was created with `root`-owned files by the legacy compose setup. Without it, ZooKeeper may fail to start with permission errors.
|
||||
</details>
|
||||
|
||||
If you had custom configurations for features like SMTP or additional ingestion processors/receivers, you will need to include those in your casting file via [patches](https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md), [custom configuration](https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files) or [environment variables](https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec) based on your previous configuration.
|
||||
<details>
|
||||
<summary><b>Docker Swarm</b> casting.yaml</summary>
|
||||
|
||||
3. Review your manifests, we suggest executing the following checks on your manifests before proceeding:
|
||||
- [ ] Validate the container images match what your deployment had, Foundry uses `latest` on generation by default.
|
||||
- [ ] If your signoz version was older than latest, please check the [upgrade path](https://signoz.io/docs/operate/upgrade/) first.
|
||||
- [ ] Check the produced manifests in `pours/deployment` match your older configurations. Extra consideration and review needs to be done on `compose.yaml` as this will be the main entry point for your new deployment.
|
||||
- [ ] The configuration files for clickhouse are now in YAML so validate your custom settings are present.
|
||||
```yaml
|
||||
# casting.yaml
|
||||
apiVersion: v1alpha1
|
||||
kind: Installation
|
||||
metadata:
|
||||
name: signoz
|
||||
spec:
|
||||
deployment:
|
||||
flavor: swarm
|
||||
mode: docker
|
||||
metastore:
|
||||
kind: sqlite
|
||||
telemetrykeeper:
|
||||
kind: zookeeper
|
||||
telemetrystore:
|
||||
spec:
|
||||
config:
|
||||
data:
|
||||
config-0-0.yaml: |
|
||||
macros:
|
||||
replica: "example01-01-1" # replace with your replica macro
|
||||
shard: "01" # replace with your shard macro
|
||||
patches:
|
||||
- target: "deployment/compose.yaml"
|
||||
operations:
|
||||
- op: replace
|
||||
path: /volumes/signoz-telemetrykeeper-0-data
|
||||
value:
|
||||
name: signoz-zookeeper-1
|
||||
- op: replace
|
||||
path: /volumes/signoz-telemetrystore-0-0-data
|
||||
value:
|
||||
name: signoz-clickhouse
|
||||
- op: replace
|
||||
path: /volumes/signoz-metastore-sqlite-0-data
|
||||
value:
|
||||
name: signoz-sqlite
|
||||
- op: add
|
||||
path: /services/signoz-telemetrykeeper-zookeeper-0/user
|
||||
value: root
|
||||
```
|
||||
|
||||
4. Execute a `docker compose down`. **Do not** include parameters such as `--volumes` (or `-v`), as it will wipe the volumes we need to maintain and reuse to avoid data loss.
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> This will generate downtime so please plan accordingly.
|
||||
> The `user: root` patch on the ZooKeeper service lets the container read and
|
||||
> write the data in your reused ZooKeeper volume, whose files the legacy setup
|
||||
> created as `root`. Without it, ZooKeeper may fail to start with permission
|
||||
> errors.
|
||||
|
||||
5. Validate the SigNoz containers are down with `docker ps -a`. Multiple containers cannot bind the same volume.
|
||||
If you had custom configuration (SMTP, extra ingestion receivers/processors,
|
||||
or custom ClickHouse settings), carry it over via [patches][patches],
|
||||
[custom config files][custom-config], or [environment variables][env-vars].
|
||||
|
||||
6. Run `foundryctl cast -f casting.yaml`. This will recreate the containers based on the spec. This process will download new container images.
|
||||
### 4. Generate and review the manifests
|
||||
|
||||
```bash
|
||||
foundryctl forge -f casting.yaml
|
||||
```
|
||||
|
||||
Review `pours/deployment/` before deploying:
|
||||
|
||||
- [ ] Container images match your current deployment. Foundry generates with
|
||||
`latest` by default; if your SigNoz version was older than latest, check the
|
||||
[upgrade path][upgrade-path] first.
|
||||
- [ ] The generated manifests match your previous configuration, especially
|
||||
`compose.yaml` (the new entry point for your deployment).
|
||||
- [ ] The ClickHouse config is now YAML rather than XML; confirm your custom
|
||||
settings carried over (see [ClickHouse configuration files][ch-config] for
|
||||
the XML-to-YAML mapping).
|
||||
|
||||
### 5. Stop the old deployment
|
||||
|
||||
Use the command for your deployment. Do **not** pass `--volumes` / `-v`; that
|
||||
would delete the data you are migrating.
|
||||
|
||||
```bash
|
||||
docker compose down # Compose
|
||||
docker stack rm signoz # Swarm
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> When `cast` is run, the migration container will execute its migrations.
|
||||
> This causes downtime, so plan accordingly.
|
||||
|
||||
## Verifying the Migration
|
||||
- SigNoz containers will be up and running.
|
||||
- Log in to the SigNoz UI and verify that data is present.
|
||||
- Signoz will run on localhost:8080
|
||||
- Validate that your data ingestion is receiving data.
|
||||
- Ingesters will receive data on localhost:4317(grpc) and localhost:4318(http)
|
||||
- Review the logs from both ClickHouse and ZooKeeper; no errors should be present.
|
||||
Confirm nothing is still bound to the volumes before continuing:
|
||||
|
||||
## Rolling Back
|
||||
Because step 4 brought the legacy stack down *without* `-v`, your original volumes
|
||||
are untouched and still hold your data. To roll back:
|
||||
```bash
|
||||
docker ps -a
|
||||
```
|
||||
|
||||
- Stop and remove the containers created by Foundry (`docker compose down`, again without `-v`).
|
||||
- Confirm the containers are gone with `docker ps -a` so nothing else is bound to the volumes.
|
||||
- Reapply your original docker compose file (`docker compose up -d`). It will reattach to the
|
||||
existing volumes and restore your prior state.
|
||||
### 6. Deploy with Foundry
|
||||
|
||||
```bash
|
||||
foundryctl cast -f casting.yaml
|
||||
```
|
||||
|
||||
This recreates the containers against your existing volumes and pulls the
|
||||
images. The migration container runs the schema migrations as part of `cast`.
|
||||
|
||||
**Prefer not to use `cast`?** The manifests in `pours/deployment/` are standard
|
||||
Docker artifacts you can apply yourself. Run the command from that directory so
|
||||
the relative config paths resolve:
|
||||
|
||||
```bash
|
||||
cd pours/deployment
|
||||
docker compose up -d # Compose
|
||||
docker stack deploy -c compose.yaml signoz # Swarm
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
- All SigNoz containers are running.
|
||||
- The UI is reachable on `http://localhost:8080`, and OTLP on `4317` (gRPC)
|
||||
and `4318` (HTTP), so already-instrumented apps and saved bookmarks keep
|
||||
working.
|
||||
- Your existing data is present in the UI, and new data is being ingested.
|
||||
- ClickHouse and ZooKeeper logs show no errors.
|
||||
|
||||
## Roll back
|
||||
|
||||
Step 5 left your volumes untouched, so your data is intact. To return to the
|
||||
previous setup:
|
||||
|
||||
1. Bring down the Foundry deployment (`docker compose down` or
|
||||
`docker stack rm signoz`, again without `-v`).
|
||||
2. Confirm the containers are gone with `docker ps -a`.
|
||||
3. Re-apply your backed-up stack: `docker compose up -d` (Compose) or
|
||||
`docker stack deploy -c docker-compose.yaml signoz` (Swarm). It reattaches
|
||||
the same volumes and restores your prior state.
|
||||
|
||||
## Troubleshooting
|
||||
- Please reach out to our community on [Slack](https://signoz.io/slack).
|
||||
|
||||
If the migration runs into trouble, reach out on [Slack][slack] or open a
|
||||
[Foundry issue][foundry-issues].
|
||||
|
||||
## References
|
||||
- [SigNoz Docker installation docs](https://signoz.io/docs/install/docker/)
|
||||
- [SigNoz documentation](https://signoz.io/docs)
|
||||
- [Foundry](https://github.com/SigNoz/foundry)
|
||||
|
||||
- [Foundry][foundry]
|
||||
- [Casting file reference][casting-ref]
|
||||
- [Custom config files][custom-config]
|
||||
- [Patches][patches]
|
||||
- [SigNoz documentation][signoz-docs]
|
||||
|
||||
[foundry]: https://github.com/SigNoz/foundry
|
||||
[install-docs]: https://signoz.io/docs/install/
|
||||
[compose-example]: https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose
|
||||
[patches]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md
|
||||
[custom-config]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files
|
||||
[env-vars]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec
|
||||
[casting-ref]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md
|
||||
[ch-config]: https://clickhouse.com/docs/operations/configuration-files
|
||||
[upgrade-path]: https://signoz.io/docs/operate/upgrade/
|
||||
[slack]: https://signoz.io/slack
|
||||
[foundry-issues]: https://github.com/SigNoz/foundry/issues
|
||||
[signoz-docs]: https://signoz.io/docs
|
||||
|
||||
1393
docs/api/openapi.yml
1393
docs/api/openapi.yml
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,55 @@ var (
|
||||
|
||||
> 💡 **Note**: Error codes must match the regex `^[a-z_]+$` otherwise the code will panic.
|
||||
|
||||
### Message
|
||||
The primary, human-readable summary of what went wrong, set when the error is created via `errors.New` / `errors.Newf`. Note there are two distinct `message` fields in the response: this top-level one states the overall failure, while each entry under [Additional](#additional) carries its own [message](#message-1) explaining one specific facet of it.
|
||||
|
||||
### Url
|
||||
An optional link to documentation that explains the error in more depth, set with `WithUrl`. It is left empty when the error has no associated doc.
|
||||
|
||||
```go
|
||||
return errors.New(errors.TypeInvalidInput, CodeBadThing, "bad thing").
|
||||
WithUrl("https://signoz.io/docs/...")
|
||||
```
|
||||
|
||||
### Additional
|
||||
`errors` is a list of supplementary details that explain the top-level `message`. Each entry has its own `message` and `suggestions`, so a single error can surface several distinct problems individually. Attach details with `WithAdditional` (message only) or `WithSuggestiveAdditional` (message plus the suggestions that belong to it):
|
||||
|
||||
#### Message
|
||||
A single, self-contained sentence describing one specific facet of the error (e.g. ``field `filed` not found``), distinct from the top-level [Message](#message). Prefer one detail per distinct problem over concatenating several into one message.
|
||||
|
||||
#### Suggestions
|
||||
The suggestions tied to that specific detail — typically a ``did you mean: `x` `` correction for the value the detail is about. These are distinct from the error-wide [Suggestions](#suggestions) below: detail-scoped suggestions never leak into the top-level list.
|
||||
|
||||
```go
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
|
||||
WithAdditional("field `field` not found")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
|
||||
WithSuggestiveAdditional("field `filed` not found", "did you mean: `field`")
|
||||
```
|
||||
|
||||
### Retry
|
||||
Carries the `delay` the client should wait before retrying, set with `WithRetryAfter`. It is `null` when the error is not retryable.
|
||||
|
||||
```go
|
||||
return errors.NewTimeoutf(CodeSlow, "upstream timed out").
|
||||
WithRetryAfter(5 * time.Second)
|
||||
```
|
||||
|
||||
### Suggestions
|
||||
`WithSuggestions` sets the error-wide `suggestions` list — hints about the error as a whole (e.g. "narrow the time range window"), as opposed to suggestions tied to a single detail. Prefer the builders in [pkg/errors/suggestions.go](/pkg/errors/suggestions.go) over hand-writing the strings so the phrasing stays consistent:
|
||||
|
||||
- `NewSuggestionsOnLevenshteinDistance(invalidInput, noun, validInputs)` — returns a ``did you mean: `x` `` correction (when a close typo match exists) followed by the valid-references list.
|
||||
- `NewValidReferences(noun, values...)` — formats a capped list as ``valid <noun> are `a`, `b` `` (e.g. `"valid fields are"`, `"valid keys are"`). Returns `""` for an empty set.
|
||||
- `NewSuggestionsFromFunc(produce)` — wraps a caller-computed correction string as a one-element ``did you mean: `x` `` slice (or nil when it returns `""`), for callers with their own matching strategy.
|
||||
|
||||
`noun` names the kind of value being suggested. Use one of the exported `Noun*` constants (`errors.NounFields`, `errors.NounKeys`, `errors.NounServices`, …) so the wording stays uniform across the codebase.
|
||||
|
||||
```go
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
|
||||
WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, validFields)...)
|
||||
```
|
||||
|
||||
## Show me some examples
|
||||
|
||||
### Using the error
|
||||
|
||||
@@ -143,6 +143,10 @@ func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*autht
|
||||
return provider.pkgAuthzService.List(ctx, orgID)
|
||||
}
|
||||
|
||||
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
return provider.pkgAuthzService.Collect(ctx, orgID)
|
||||
}
|
||||
|
||||
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.Role, error) {
|
||||
return provider.pkgAuthzService.ListByOrgIDAndNames(ctx, orgID, names)
|
||||
}
|
||||
@@ -370,7 +374,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
}
|
||||
|
||||
for _, cb := range provider.onBeforeRoleDelete {
|
||||
if err := cb(ctx, orgID, id); err != nil {
|
||||
if err := cb(ctx, orgID, id, role.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,10 @@ func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, m
|
||||
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
|
||||
}
|
||||
|
||||
func (module *module) GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
|
||||
return module.pkgDashboardModule.GetByMetricNamesV2(ctx, orgID, metricNames)
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.List(ctx, orgID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
package implmetricreductionrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
)
|
||||
|
||||
var (
|
||||
reductionRulesTable = telemetrymetrics.DBName + "." + telemetrymetrics.ReductionRulesTableName
|
||||
metadataTable = telemetrymetrics.DBName + "." + telemetrymetrics.AttributesMetadataTableName
|
||||
bufferSeriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4BufferTableName
|
||||
)
|
||||
|
||||
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
|
||||
|
||||
type volumeRow struct {
|
||||
MetricName string
|
||||
Ingested uint64
|
||||
Reduced uint64
|
||||
}
|
||||
|
||||
type volumePoint struct {
|
||||
TimestampMs int64
|
||||
Ingested uint64
|
||||
Reduced uint64
|
||||
}
|
||||
|
||||
type clickhouse struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
threads int
|
||||
}
|
||||
|
||||
func newClickhouse(telemetryStore telemetrystore.TelemetryStore, threads int) *clickhouse {
|
||||
return &clickhouse{telemetryStore: telemetryStore, threads: threads}
|
||||
}
|
||||
|
||||
func (c *clickhouse) withThreads(ctx context.Context) context.Context {
|
||||
return ctxtypes.SetClickhouseMaxThreads(ctx, c.threads)
|
||||
}
|
||||
|
||||
func floorToTimeSeriesBucket(ms int64) int64 {
|
||||
return ms - (ms % timeSeriesBucketMilli)
|
||||
}
|
||||
|
||||
func strictEffectiveFrom(sb *sqlbuilder.SelectBuilder, metricNames []string, effectiveFrom map[string]int64) string {
|
||||
names := make([]any, 0, len(metricNames))
|
||||
froms := make([]any, 0, len(metricNames))
|
||||
for _, name := range metricNames {
|
||||
names = append(names, name)
|
||||
froms = append(froms, effectiveFrom[name])
|
||||
}
|
||||
return "unix_milli >= transform(metric_name, " + sb.Var(names) + ", " + sb.Var(froms) + ", 0)"
|
||||
}
|
||||
|
||||
func (c *clickhouse) Sync(ctx context.Context, metricName string, labels []string, matchType string, effectiveFromMs int64, deleted bool, updatedAt time.Time) error {
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ib := sqlbuilder.NewInsertBuilder()
|
||||
ib.InsertInto(reductionRulesTable)
|
||||
ib.Cols("metric_name", "labels", "match_type", "effective_from_unix_milli", "deleted", "updated_at")
|
||||
ib.Values(metricName, labels, matchType, effectiveFromMs, deleted, updatedAt)
|
||||
|
||||
query, args := ib.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
if err := c.telemetryStore.ClickhouseDB().Exec(ctx, query, args...); err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "failed to sync reduction rule to clickhouse")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) AttributeKeys(ctx context.Context, metricName string, startMs, endMs int64) ([]string, error) {
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("attr_name")
|
||||
sb.Distinct()
|
||||
sb.From(metadataTable)
|
||||
sb.Where(
|
||||
sb.E("metric_name", metricName),
|
||||
"NOT startsWith(attr_name, '__')",
|
||||
sb.GE("last_reported_unix_milli", startMs),
|
||||
sb.LE("first_reported_unix_milli", endMs),
|
||||
)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch metric attribute keys")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan attribute key")
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string, keptLabels []string, startMs, endMs int64) (uint64, uint64, error) {
|
||||
ctx = c.withThreads(ctx)
|
||||
startMs = floorToTimeSeriesBucket(startMs)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
reducedExpr := "1"
|
||||
if len(keptLabels) > 0 {
|
||||
reducedExpr = "uniq(("
|
||||
for i, label := range keptLabels {
|
||||
if i > 0 {
|
||||
reducedExpr += ", "
|
||||
}
|
||||
reducedExpr += "JSONExtractString(labels, " + sb.Var(label) + ")"
|
||||
}
|
||||
reducedExpr += "))"
|
||||
}
|
||||
|
||||
sb.Select("uniq(fingerprint)", reducedExpr)
|
||||
sb.From(bufferSeriesTable)
|
||||
conds := []string{
|
||||
sb.E("metric_name", metricName),
|
||||
sb.GE("unix_milli", startMs),
|
||||
sb.LT("unix_milli", endMs),
|
||||
sb.E("is_reduced", false),
|
||||
}
|
||||
sb.Where(conds...)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
var current, reduced uint64
|
||||
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(¤t, &reduced); err != nil {
|
||||
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to estimate reduction impact")
|
||||
}
|
||||
if len(keptLabels) == 0 && current == 0 {
|
||||
reduced = 0
|
||||
}
|
||||
if reduced > current {
|
||||
reduced = current
|
||||
}
|
||||
return current, reduced, nil
|
||||
}
|
||||
|
||||
// VolumeByMetric returns ingested vs reduced series counts per metric.
|
||||
func (c *clickhouse) VolumeByMetric(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]volumeRow, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return map[string]volumeRow{}, nil
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.ingestedSeriesCount(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reduced, err := c.reducedSeriesCount(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]volumeRow, len(metricNames))
|
||||
for metricName, count := range ingested {
|
||||
out[metricName] = volumeRow{MetricName: metricName, Ingested: count, Reduced: out[metricName].Reduced}
|
||||
}
|
||||
for metricName, count := range reduced {
|
||||
row := out[metricName]
|
||||
row.MetricName = metricName
|
||||
row.Reduced = count
|
||||
out[metricName] = row
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ingestedSeriesCount counts distinct raw fingerprints per metric from the samples buffer over the
|
||||
// window.
|
||||
func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "uniq(fingerprint)")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
conds := []string{
|
||||
sb.In("metric_name", names...),
|
||||
sb.GE("unix_milli", startMs),
|
||||
sb.LT("unix_milli", endMs),
|
||||
}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested series")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]uint64, len(metricNames))
|
||||
for rows.Next() {
|
||||
var (
|
||||
metricName string
|
||||
count uint64
|
||||
)
|
||||
if err := rows.Scan(&metricName, &count); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
|
||||
}
|
||||
out[metricName] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// reducedSeriesCount counts distinct reduced_fingerprints per metric, summed across the two 60s
|
||||
// reduced sample tables.
|
||||
func (c *clickhouse) reducedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
|
||||
out := make(map[string]uint64, len(metricNames))
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
counts, err := c.reducedSeriesCountForTable(ctx, telemetrymetrics.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for metricName, count := range counts {
|
||||
out[metricName] += count
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) reducedSeriesCountForTable(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "uniq(reduced_fingerprint)")
|
||||
sb.From(table)
|
||||
conds := []string{
|
||||
sb.In("metric_name", names...),
|
||||
sb.GE("unix_milli", startMs),
|
||||
sb.LT("unix_milli", endMs),
|
||||
}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced series")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]uint64, len(metricNames))
|
||||
for rows.Next() {
|
||||
var (
|
||||
metricName string
|
||||
count uint64
|
||||
)
|
||||
if err := rows.Scan(&metricName, &count); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
|
||||
}
|
||||
out[metricName] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RankByVolume ranks metrics by ingested/reduced series volume. Like VolumeByMetric, the counts read
|
||||
// the samples tables with a strict effective_from gate; the reduced count sums distinct
|
||||
// reduced_fingerprints across the two 60s reduced sample tables.
|
||||
func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, orderBy metricreductionruletypes.ReductionRuleOrderBy, order metricreductionruletypes.Order, startMs, endMs int64, offset, limit int) ([]volumeRow, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return []volumeRow{}, nil
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
orderExpr := "ingested"
|
||||
switch orderBy {
|
||||
case metricreductionruletypes.OrderByReducedVolume:
|
||||
orderExpr = "reduced"
|
||||
case metricreductionruletypes.OrderByReduction:
|
||||
orderExpr = "if(ingested = 0, 0, (toFloat64(ingested) - toFloat64(reduced)) / toFloat64(ingested))"
|
||||
}
|
||||
direction := "ASC"
|
||||
if order == metricreductionruletypes.OrderDesc {
|
||||
direction = "DESC"
|
||||
}
|
||||
|
||||
ingestedTable := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName
|
||||
reducedLast := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedLastTableName
|
||||
reducedSum := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedSumTableName
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("base.metric_name AS metric_name", "ifNull(i.cnt, 0) AS ingested", "ifNull(d.cnt, 0) AS reduced")
|
||||
sb.From("(SELECT arrayJoin(" + sb.Var(metricNames) + ") AS metric_name) AS base")
|
||||
sb.JoinWithOption(
|
||||
sqlbuilder.LeftJoin,
|
||||
"(SELECT metric_name, uniq(fingerprint) AS cnt FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
|
||||
"base.metric_name = i.metric_name",
|
||||
)
|
||||
// Reduced series are spread across two type-specific tables; union the per-table distinct
|
||||
// reduced_fingerprints and sum per metric (a metric only lands in the table matching its type).
|
||||
sb.JoinWithOption(
|
||||
sqlbuilder.LeftJoin,
|
||||
"(SELECT metric_name, sum(cnt) AS cnt FROM ("+
|
||||
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
|
||||
" UNION ALL "+
|
||||
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
|
||||
") GROUP BY metric_name) AS d",
|
||||
"base.metric_name = d.metric_name",
|
||||
)
|
||||
sb.OrderBy(orderExpr + " " + direction)
|
||||
if limit > 0 {
|
||||
sb.Limit(limit).Offset(offset)
|
||||
}
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to rank reduction rules by volume")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]volumeRow, 0, len(metricNames))
|
||||
for rows.Next() {
|
||||
var row volumeRow
|
||||
if err := rows.Scan(&row.MetricName, &row.Ingested, &row.Reduced); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan volume row")
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (c *clickhouse) SampleVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, uint64, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.countRawSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
last, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
sum, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return ingested, min(last+sum, ingested), nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) countRawSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("count()")
|
||||
sb.From(table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
var count uint64
|
||||
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
|
||||
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested samples")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) countReducedSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
// Reduced tables key the series on reduced_fingerprint (not fingerprint); dedupe ReplacingMergeTree recomputes.
|
||||
sb.Select("uniq(reduced_fingerprint, unix_milli)")
|
||||
sb.From(table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
var count uint64
|
||||
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
|
||||
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced samples")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// SeriesTimeseries returns ingested vs reduced series per 60s bucket from the samples tables, gated
|
||||
// to each metric's strict effective_from (see strictEffectiveFrom).
|
||||
func (c *clickhouse) SeriesTimeseries(ctx context.Context, allMetrics, reducedMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
|
||||
if len(allMetrics) == 0 {
|
||||
return []volumePoint{}, nil
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.ingestedSeriesByBucket(ctx, allMetrics, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
retained := make(map[int64]uint64)
|
||||
if len(reducedMetrics) > 0 {
|
||||
reduced, err := c.reducedSeriesByBucket(ctx, reducedMetrics, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ts, count := range reduced {
|
||||
retained[ts] += count
|
||||
}
|
||||
}
|
||||
reducedSet := make(map[string]struct{}, len(reducedMetrics))
|
||||
for _, name := range reducedMetrics {
|
||||
reducedSet[name] = struct{}{}
|
||||
}
|
||||
nonReduced := make([]string, 0, len(allMetrics))
|
||||
for _, name := range allMetrics {
|
||||
if _, ok := reducedSet[name]; !ok {
|
||||
nonReduced = append(nonReduced, name)
|
||||
}
|
||||
}
|
||||
if len(nonReduced) > 0 {
|
||||
nonReducedIngested, err := c.ingestedSeriesByBucket(ctx, nonReduced, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ts, count := range nonReducedIngested {
|
||||
retained[ts] += count
|
||||
}
|
||||
}
|
||||
|
||||
return mergeVolumePoints(ingested, retained), nil
|
||||
}
|
||||
|
||||
func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
|
||||
buckets := make(map[int64]struct{}, len(ingested))
|
||||
for ts := range ingested {
|
||||
buckets[ts] = struct{}{}
|
||||
}
|
||||
for ts := range reduced {
|
||||
buckets[ts] = struct{}{}
|
||||
}
|
||||
timestamps := make([]int64, 0, len(buckets))
|
||||
for ts := range buckets {
|
||||
timestamps = append(timestamps, ts)
|
||||
}
|
||||
slices.Sort(timestamps)
|
||||
|
||||
points := make([]volumePoint, 0, len(timestamps))
|
||||
for _, ts := range timestamps {
|
||||
points = append(points, volumePoint{
|
||||
TimestampMs: ts,
|
||||
Ingested: ingested[ts],
|
||||
Reduced: reduced[ts],
|
||||
})
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
// ingestedSeriesByBucket counts distinct raw fingerprints per hourly bucket from the samples buffer.
|
||||
func (c *clickhouse) ingestedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
|
||||
sb.Select(bucketExpr, "uniq(fingerprint)")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
sb.GroupBy("bucket")
|
||||
|
||||
return c.scanBuckets(ctx, sb)
|
||||
}
|
||||
|
||||
// reducedSeriesByBucket counts distinct reduced_fingerprints per hourly bucket, summed across the two
|
||||
// reduced sample tables (a metric only lands in the table matching its type, so per-bucket sums are
|
||||
// exact).
|
||||
func (c *clickhouse) reducedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
out := make(map[int64]uint64)
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
|
||||
sb.Select(bucketExpr, "uniq(reduced_fingerprint)")
|
||||
sb.From(telemetrymetrics.DBName + "." + table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
sb.GroupBy("bucket")
|
||||
|
||||
counts, err := c.scanBuckets(ctx, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ts, count := range counts {
|
||||
out[ts] += count
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) scanBuckets(ctx context.Context, sb *sqlbuilder.SelectBuilder) (map[int64]uint64, error) {
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to bucket series by time")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[int64]uint64)
|
||||
for rows.Next() {
|
||||
var (
|
||||
ts int64
|
||||
count uint64
|
||||
)
|
||||
if err := rows.Scan(&ts, &count); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series bucket")
|
||||
}
|
||||
out[ts] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
571
ee/modules/metricreductionrule/implmetricreductionrule/module.go
Normal file
571
ee/modules/metricreductionrule/implmetricreductionrule/module.go
Normal file
@@ -0,0 +1,571 @@
|
||||
package implmetricreductionrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const (
|
||||
// effectiveFromMargin delays effective_from so the collector picks up the synced rule before it
|
||||
// goes live; it must be >= the collector's rule-refresh interval (see signoz-otel-collector#839).
|
||||
effectiveFromMargin = 5 * time.Minute
|
||||
defaultPreviewLookback = 24 * time.Hour
|
||||
|
||||
pricePerMillionSamplesUSD = 0.1
|
||||
monthDuration = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store metricreductionruletypes.Store
|
||||
ch *clickhouse
|
||||
dashboard dashboard.Module
|
||||
ruleStore ruletypes.RuleStore
|
||||
licensing licensing.Licensing
|
||||
flagger flagger.Flagger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewModule(sqlStore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, licensing licensing.Licensing, flagger flagger.Flagger, metadataStore telemetrytypes.MetadataStore, providerSettings factory.ProviderSettings, threads int) metricreductionrule.Module {
|
||||
scoped := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule")
|
||||
return &module{
|
||||
store: NewStore(sqlStore),
|
||||
ch: newClickhouse(telemetryStore, threads),
|
||||
dashboard: dashboardModule,
|
||||
ruleStore: sqlrulestore.NewRuleStore(sqlStore, queryParser, providerSettings),
|
||||
licensing: licensing,
|
||||
flagger: flagger,
|
||||
metadataStore: metadataStore,
|
||||
logger: scoped.Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) checkAccess(ctx context.Context, orgID valuer.UUID) error {
|
||||
if !m.flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
return errors.Newf(errors.TypeUnsupported, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupported, "metric volume control is not enabled")
|
||||
}
|
||||
if _, err := m.licensing.GetActive(ctx, orgID); err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "metric volume control requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) (*metricreductionruletypes.GettableReductionRules, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
switch params.OrderBy {
|
||||
case metricreductionruletypes.OrderByMetricName, metricreductionruletypes.OrderByLastUpdated:
|
||||
return m.listSortedByColumn(ctx, orgID, params, startMs, endMs)
|
||||
default:
|
||||
return m.listSortedByVolume(ctx, orgID, params, startMs, endMs)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) listSortedByColumn(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
|
||||
domainRules, total, err := m.store.List(ctx, orgID, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metricNames := make([]string, len(domainRules))
|
||||
effectiveFrom := make(map[string]int64, len(domainRules))
|
||||
for i, rule := range domainRules {
|
||||
metricNames[i] = rule.MetricName
|
||||
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
|
||||
}
|
||||
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(domainRules))
|
||||
for _, rule := range domainRules {
|
||||
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName]))
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
|
||||
}
|
||||
|
||||
func (m *module) listSortedByVolume(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
|
||||
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{Search: params.Search, MetricName: params.MetricName})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total == 0 {
|
||||
return &metricreductionruletypes.GettableReductionRules{Rules: []metricreductionruletypes.GettableReductionRule{}, Total: 0}, nil
|
||||
}
|
||||
|
||||
metricNames := make([]string, len(allRules))
|
||||
effectiveFrom := make(map[string]int64, len(allRules))
|
||||
ruleByMetric := make(map[string]*metricreductionruletypes.ReductionRule, len(allRules))
|
||||
for i, rule := range allRules {
|
||||
metricNames[i] = rule.MetricName
|
||||
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
|
||||
ruleByMetric[rule.MetricName] = rule
|
||||
}
|
||||
|
||||
ranked, err := m.ch.RankByVolume(ctx, metricNames, effectiveFrom, params.OrderBy, params.Order, startMs, endMs, params.Offset, params.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(ranked))
|
||||
for _, row := range ranked {
|
||||
rule, ok := ruleByMetric[row.MetricName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, withVolume(toGettableReductionRule(rule), row))
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
|
||||
}
|
||||
|
||||
func (m *module) Create(ctx context.Context, orgID valuer.UUID, userEmail string, req *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.validateMetricForReduction(ctx, orgID, req.MetricName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
rule := metricreductionruletypes.NewReductionRule(orgID, req.MetricName, req.MatchType, req.Labels, now.Add(effectiveFromMargin), userEmail)
|
||||
|
||||
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := m.store.Create(ctx, rule); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.ch.Sync(ctx, rule.MetricName, rule.Labels, rule.MatchType.StringValue(), rule.EffectiveFrom.UnixMilli(), false, rule.UpdatedAt)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gettable := toGettableReductionRule(rule)
|
||||
return &gettable, nil
|
||||
}
|
||||
|
||||
func (m *module) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.GettableReductionRule, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule, err := m.store.GetByID(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gettable := toGettableReductionRule(rule)
|
||||
return &gettable, nil
|
||||
}
|
||||
|
||||
func (m *module) UpdateByID(ctx context.Context, orgID valuer.UUID, userEmail string, id valuer.UUID, req *metricreductionruletypes.UpdatableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err := m.store.GetByID(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
existing.MatchType = req.MatchType
|
||||
existing.Labels = metricreductionruletypes.LabelList(req.Labels)
|
||||
existing.EffectiveFrom = now.Add(effectiveFromMargin)
|
||||
existing.UpdatedAt = now
|
||||
existing.UpdatedBy = userEmail
|
||||
|
||||
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := m.store.Upsert(ctx, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.ch.Sync(ctx, existing.MetricName, existing.Labels, existing.MatchType.StringValue(), existing.EffectiveFrom.UnixMilli(), false, existing.UpdatedAt)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gettable := toGettableReductionRule(existing)
|
||||
return &gettable, nil
|
||||
}
|
||||
|
||||
func (m *module) DeleteByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
rule, err := m.store.GetByID(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
effectiveFromMs := now.Add(effectiveFromMargin).UnixMilli()
|
||||
return m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := m.store.DeleteByID(ctx, orgID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.ch.Sync(ctx, rule.MetricName, []string{}, metricreductionruletypes.MatchTypeDrop.StringValue(), effectiveFromMs, true, now)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *module) Preview(ctx context.Context, orgID valuer.UUID, req *metricreductionruletypes.PostableReductionRulePreview) (*metricreductionruletypes.GettableReductionRulePreview, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.validateMetricForReduction(ctx, orgID, req.MetricName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookback := time.Duration(req.LookbackMs) * time.Millisecond
|
||||
if lookback <= 0 {
|
||||
lookback = defaultPreviewLookback
|
||||
}
|
||||
now := time.Now()
|
||||
startMs := now.Add(-lookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
current, reduced, reductionPercent, dropped, err := m.estimateVolume(ctx, req.MetricName, req.MatchType, req.Labels, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Baseline is what the metric keeps today (its current rule, or raw if none) so the preview reads
|
||||
// as current -> proposed.
|
||||
currentReduced := current
|
||||
if existing, gerr := m.store.Get(ctx, orgID, req.MetricName); gerr == nil {
|
||||
if _, existingReduced, _, _, eerr := m.estimateVolume(ctx, req.MetricName, existing.MatchType, existing.Labels, startMs, endMs); eerr == nil {
|
||||
currentReduced = existingReduced
|
||||
}
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRulePreview{
|
||||
IngestedSeries: current,
|
||||
CurrentRetainedSeries: currentReduced,
|
||||
RetainedSeries: reduced,
|
||||
ReductionPercent: reductionPercent,
|
||||
DroppedLabels: dropped,
|
||||
AffectedAssets: m.relatedAssetImpact(ctx, orgID, req.MetricName, dropped),
|
||||
EffectiveFrom: now.Add(effectiveFromMargin),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreductionruletypes.GettableReductionRuleStats, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total == 0 {
|
||||
return &metricreductionruletypes.GettableReductionRuleStats{}, nil
|
||||
}
|
||||
|
||||
metricNames := make([]string, len(allRules))
|
||||
effectiveFrom := make(map[string]int64, len(allRules))
|
||||
for i, rule := range allRules {
|
||||
metricNames[i] = rule.MetricName
|
||||
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
|
||||
}
|
||||
|
||||
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ingestedSeries, retainedSeries uint64
|
||||
reducedMetricNames := make([]string, 0, len(volumes))
|
||||
reducedEffectiveFrom := make(map[string]int64, len(volumes))
|
||||
for name, volume := range volumes {
|
||||
ingestedSeries += volume.Ingested
|
||||
retained := effectiveRetained(volume.Ingested, volume.Reduced)
|
||||
retainedSeries += retained
|
||||
if retained < volume.Ingested {
|
||||
reducedMetricNames = append(reducedMetricNames, name)
|
||||
reducedEffectiveFrom[name] = effectiveFrom[name]
|
||||
}
|
||||
}
|
||||
|
||||
ingestedSamples, reducedSamples, err := m.ch.SampleVolume(ctx, reducedMetricNames, reducedEffectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRuleStats{
|
||||
IngestedSeries: ingestedSeries,
|
||||
RetainedSeries: retainedSeries,
|
||||
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ingestedSamples, reducedSamples, startMs, endMs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// monthlySavingsUSD extrapolates the windowed sample reduction to a monthly figure at the per-sample
|
||||
// list price. Ingested is gated to effective_from upstream, so pre-activation hours don't inflate it.
|
||||
func monthlySavingsUSD(ingestedSamples, reducedSamples uint64, startMs, endMs int64) float64 {
|
||||
if reducedSamples >= ingestedSamples || endMs <= startMs {
|
||||
return 0
|
||||
}
|
||||
savedSamples := float64(ingestedSamples - reducedSamples)
|
||||
monthlySamples := savedSamples * float64(monthDuration.Milliseconds()) / float64(endMs-startMs)
|
||||
return monthlySamples / 1_000_000 * pricePerMillionSamplesUSD
|
||||
}
|
||||
|
||||
func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuildertypesv5.QueryRangeResponse, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
allRules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metricNames := make([]string, len(allRules))
|
||||
effectiveFrom := make(map[string]int64, len(allRules))
|
||||
for i, rule := range allRules {
|
||||
metricNames[i] = rule.MetricName
|
||||
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
|
||||
}
|
||||
|
||||
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reducedNames := make([]string, 0, len(volumes))
|
||||
for name, volume := range volumes {
|
||||
if effectiveRetained(volume.Ingested, volume.Reduced) < volume.Ingested {
|
||||
reducedNames = append(reducedNames, name)
|
||||
}
|
||||
}
|
||||
|
||||
points, err := m.ch.SeriesTimeseries(ctx, metricNames, reducedNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildVolumeTimeseries(points), nil
|
||||
}
|
||||
|
||||
func buildVolumeTimeseries(points []volumePoint) *querybuildertypesv5.QueryRangeResponse {
|
||||
ingested := make([]*querybuildertypesv5.TimeSeriesValue, 0, len(points))
|
||||
reduced := make([]*querybuildertypesv5.TimeSeriesValue, 0, len(points))
|
||||
for _, point := range points {
|
||||
ingested = append(ingested, &querybuildertypesv5.TimeSeriesValue{Timestamp: point.TimestampMs, Value: float64(point.Ingested)})
|
||||
reduced = append(reduced, &querybuildertypesv5.TimeSeriesValue{Timestamp: point.TimestampMs, Value: float64(point.Reduced)})
|
||||
}
|
||||
|
||||
return &querybuildertypesv5.QueryRangeResponse{
|
||||
Type: querybuildertypesv5.RequestTypeTimeSeries,
|
||||
Data: querybuildertypesv5.QueryData{
|
||||
Results: []any{
|
||||
&querybuildertypesv5.TimeSeriesData{
|
||||
QueryName: "reduction_volume",
|
||||
Aggregations: []*querybuildertypesv5.AggregationBucket{
|
||||
{
|
||||
Series: []*querybuildertypesv5.TimeSeries{
|
||||
{Labels: []*querybuildertypesv5.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "series"}, Value: "ingested"}}, Values: ingested},
|
||||
{Labels: []*querybuildertypesv5.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "series"}, Value: "retained"}}, Values: reduced},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) validateMetricForReduction(ctx context.Context, orgID valuer.UUID, metricName string) error {
|
||||
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, metricName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lastSeen[metricName] == 0 {
|
||||
return errors.NewNotFoundf(errors.CodeNotFound, "metric not found: %q", metricName)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startTs := uint64(now.Add(-defaultPreviewLookback).UnixMilli())
|
||||
endTs := uint64(now.UnixMilli())
|
||||
_, types, _, err := m.metadataStore.FetchTemporalityAndTypeMulti(ctx, orgID, startTs, endTs, metricName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if types[metricName] == metrictypes.ExpHistogramType {
|
||||
return errors.Newf(errors.TypeInvalidInput, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupportedMetricType,
|
||||
"exponential histogram metrics cannot be reduced in v1")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metricName string, dropped []string) []metricreductionruletypes.AffectedAsset {
|
||||
affected := make([]metricreductionruletypes.AffectedAsset, 0)
|
||||
droppedSet := make(map[string]struct{}, len(dropped))
|
||||
for _, label := range dropped {
|
||||
droppedSet[label] = struct{}{}
|
||||
}
|
||||
|
||||
if dashboards, err := m.dashboard.GetByMetricNames(ctx, orgID, []string{metricName}); err != nil {
|
||||
m.logger.WarnContext(ctx, "failed to fetch related dashboards for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
|
||||
} else {
|
||||
for _, item := range dashboards[metricName] {
|
||||
usedLabels := append(splitCSV(item["group_by"]), splitCSV(item["filter_by"])...)
|
||||
affected = append(affected, metricreductionruletypes.AffectedAsset{
|
||||
Type: metricreductionruletypes.AssetTypeDashboard,
|
||||
ID: item["dashboard_id"],
|
||||
Name: item["dashboard_name"],
|
||||
Widget: &metricreductionruletypes.AffectedWidget{ID: item["widget_id"], Name: item["widget_name"]},
|
||||
ImpactedLabels: intersectLabels(usedLabels, droppedSet),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if alerts, err := m.ruleStore.GetStoredRulesByMetricName(ctx, orgID.String(), metricName); err != nil {
|
||||
m.logger.WarnContext(ctx, "failed to fetch related alerts for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
|
||||
} else {
|
||||
for _, a := range alerts {
|
||||
affected = append(affected, metricreductionruletypes.AffectedAsset{
|
||||
Type: metricreductionruletypes.AssetTypeAlert,
|
||||
ID: a.AlertID,
|
||||
Name: a.AlertName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return affected
|
||||
}
|
||||
|
||||
func toGettableReductionRule(rule *metricreductionruletypes.ReductionRule) metricreductionruletypes.GettableReductionRule {
|
||||
return metricreductionruletypes.GettableReductionRule{
|
||||
Identifiable: rule.Identifiable,
|
||||
TimeAuditable: rule.TimeAuditable,
|
||||
UserAuditable: rule.UserAuditable,
|
||||
MetricName: rule.MetricName,
|
||||
MatchType: rule.MatchType,
|
||||
Labels: rule.Labels,
|
||||
EffectiveFrom: rule.EffectiveFrom,
|
||||
Active: !rule.EffectiveFrom.After(time.Now()),
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveRetained(ingested, reduced uint64) uint64 {
|
||||
if reduced == 0 || reduced > ingested {
|
||||
return ingested
|
||||
}
|
||||
return reduced
|
||||
}
|
||||
|
||||
func withVolume(rule metricreductionruletypes.GettableReductionRule, volume volumeRow) metricreductionruletypes.GettableReductionRule {
|
||||
rule.IngestedSeries = volume.Ingested
|
||||
rule.RetainedSeries = effectiveRetained(volume.Ingested, volume.Reduced)
|
||||
if volume.Ingested > 0 {
|
||||
rule.ReductionPercent = (1 - float64(rule.RetainedSeries)/float64(volume.Ingested)) * 100
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func intersectLabels(keys []string, droppedSet map[string]struct{}) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
for _, key := range keys {
|
||||
if _, ok := droppedSet[key]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(s, ",")
|
||||
}
|
||||
|
||||
func resolveDroppedKept(matchType metricreductionruletypes.MatchType, ruleLabels, keys []string) (dropped, kept []string) {
|
||||
ruleSet := make(map[string]struct{}, len(ruleLabels))
|
||||
for _, l := range ruleLabels {
|
||||
ruleSet[l] = struct{}{}
|
||||
}
|
||||
|
||||
for _, k := range keys {
|
||||
if metricreductionruletypes.IsProtectedLabel(k) {
|
||||
kept = append(kept, k)
|
||||
continue
|
||||
}
|
||||
_, listed := ruleSet[k]
|
||||
drop := listed
|
||||
if matchType == metricreductionruletypes.MatchTypeKeep {
|
||||
drop = !listed
|
||||
}
|
||||
if drop {
|
||||
dropped = append(dropped, k)
|
||||
} else {
|
||||
kept = append(kept, k)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(dropped)
|
||||
sort.Strings(kept)
|
||||
return dropped, kept
|
||||
}
|
||||
|
||||
func (m *module) estimateVolume(ctx context.Context, metricName string, matchType metricreductionruletypes.MatchType, labels []string, startMs, endMs int64) (current uint64, reduced uint64, reductionPercent float64, dropped []string, err error) {
|
||||
keys, err := m.ch.AttributeKeys(ctx, metricName, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, 0, nil, err
|
||||
}
|
||||
dropped, kept := resolveDroppedKept(matchType, labels, keys)
|
||||
|
||||
current, reduced, err = m.ch.EstimateCardinality(ctx, metricName, kept, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, 0, nil, err
|
||||
}
|
||||
if current > 0 && reduced <= current {
|
||||
reductionPercent = (1 - float64(reduced)/float64(current)) * 100
|
||||
}
|
||||
return current, reduced, reductionPercent, dropped, nil
|
||||
}
|
||||
145
ee/modules/metricreductionrule/implmetricreductionrule/store.go
Normal file
145
ee/modules/metricreductionrule/implmetricreductionrule/store.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package implmetricreductionrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) metricreductionruletypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) ([]*metricreductionruletypes.ReductionRule, int, error) {
|
||||
column := "metric_name"
|
||||
if params.OrderBy == metricreductionruletypes.OrderByLastUpdated {
|
||||
column = "updated_at"
|
||||
}
|
||||
direction := "ASC"
|
||||
if params.Order == metricreductionruletypes.OrderDesc {
|
||||
direction = "DESC"
|
||||
}
|
||||
|
||||
rules := make([]*metricreductionruletypes.ReductionRule, 0)
|
||||
query := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&rules).
|
||||
Where("org_id = ?", orgID).
|
||||
Order(column + " " + direction)
|
||||
if params.Search != "" {
|
||||
query = query.Where("metric_name LIKE ?", "%"+params.Search+"%")
|
||||
}
|
||||
if params.MetricName != "" {
|
||||
query = query.Where("metric_name = ?", params.MetricName)
|
||||
}
|
||||
if params.Limit > 0 {
|
||||
query = query.Limit(params.Limit).Offset(params.Offset)
|
||||
}
|
||||
|
||||
total, err := query.ScanAndCount(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rules, total, nil
|
||||
}
|
||||
|
||||
func (s *store) Get(ctx context.Context, orgID valuer.UUID, metricName string) (*metricreductionruletypes.ReductionRule, error) {
|
||||
rule := new(metricreductionruletypes.ReductionRule)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(rule).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("metric_name = ?", metricName).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, s.sqlstore.WrapNotFoundErrf(err, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found for metric %q", metricName)
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (s *store) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.ReductionRule, error) {
|
||||
rule := new(metricreductionruletypes.ReductionRule)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(rule).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, s.sqlstore.WrapNotFoundErrf(err, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found with id %q", id.String())
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (s *store) Create(ctx context.Context, rule *metricreductionruletypes.ReductionRule) error {
|
||||
res, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(rule).
|
||||
On("CONFLICT (org_id, metric_name) DO NOTHING").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeAlreadyExists, metricreductionruletypes.ErrCodeMetricReductionRuleAlreadyExists,
|
||||
"a reduction rule for metric %q already exists", rule.MetricName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) Upsert(ctx context.Context, rule *metricreductionruletypes.ReductionRule) error {
|
||||
_, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(rule).
|
||||
On("CONFLICT (org_id, metric_name) DO UPDATE").
|
||||
Set("match_type = EXCLUDED.match_type").
|
||||
Set("labels = EXCLUDED.labels").
|
||||
Set("effective_from = EXCLUDED.effective_from").
|
||||
Set("updated_at = EXCLUDED.updated_at").
|
||||
Set("updated_by = EXCLUDED.updated_by").
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) DeleteByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
res, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*metricreductionruletypes.ReductionRule)(nil)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found with id %q", id.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
|
||||
}
|
||||
@@ -107,6 +107,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
metricsReduction := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureEnableMetricsReduction.String()),
|
||||
Active: metricsReduction,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -152,3 +152,7 @@ func (f *formatter) LowerExpression(expression string) []byte {
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) EscapeLikePattern(value string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"role_edit": "Edit Role",
|
||||
"role_create": "Create Role",
|
||||
"members": "Members",
|
||||
"service_accounts": "Service Accounts",
|
||||
"mcp_server": "MCP Server"
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
|
||||
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
|
||||
"ROLE_DETAILS": "SigNoz | Role Details",
|
||||
"ROLE_CREATE": "SigNoz | Create Role",
|
||||
"ROLE_EDIT": "SigNoz | Edit Role",
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Extracts unique fenced code block language identifiers from all .md files under frontend/src/
|
||||
# Usage: bash frontend/scripts/extract-md-languages.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SRC_DIR="$SCRIPT_DIR/../src"
|
||||
|
||||
grep -roh '```[a-zA-Z0-9_+-]*' "$SRC_DIR" --include='*.md' \
|
||||
| sed 's/^```//' \
|
||||
| grep -v '^$' \
|
||||
| sort -u
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Validates that all fenced code block languages used in .md files are registered
|
||||
# in the syntax highlighter.
|
||||
# Usage: bash frontend/scripts/validate-md-languages.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SYNTAX_HIGHLIGHTER="$SCRIPT_DIR/../src/components/MarkdownRenderer/syntaxHighlighter.ts"
|
||||
|
||||
# Get all languages used in .md files
|
||||
md_languages=$("$SCRIPT_DIR/extract-md-languages.sh")
|
||||
|
||||
# Get all registered languages from syntaxHighlighter.ts
|
||||
registered_languages=$(grep -oP "registerLanguage\('\K[^']+" "$SYNTAX_HIGHLIGHTER" | sort -u)
|
||||
|
||||
missing_languages=()
|
||||
|
||||
for lang in $md_languages; do
|
||||
# Skip ai-* block markers — these are custom AI block types rendered by
|
||||
# RichCodeBlock as React components (e.g. ActionBlock, LineChartBlock),
|
||||
# not real syntax languages, so they don't need highlighter registration.
|
||||
if [[ "$lang" == ai-* ]]; then
|
||||
continue
|
||||
fi
|
||||
if ! echo "$registered_languages" | grep -qx "$lang"; then
|
||||
missing_languages+=("$lang")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing_languages[@]} -gt 0 ]; then
|
||||
echo "Error: The following languages are used in .md files but not registered in syntaxHighlighter.ts:"
|
||||
for lang in "${missing_languages[@]}"; do
|
||||
echo " - $lang"
|
||||
done
|
||||
echo ""
|
||||
echo "Please add them to: frontend/src/components/MarkdownRenderer/syntaxHighlighter.ts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All markdown code block languages are registered in syntaxHighlighter.ts"
|
||||
@@ -3,12 +3,12 @@ import { matchPath, Redirect, useLocation } from 'react-router-dom';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
|
||||
@@ -37,11 +37,11 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
activeLicense,
|
||||
isFetchingActiveLicense,
|
||||
trialInfo,
|
||||
featureFlags,
|
||||
} = useAppContext();
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
|
||||
|
||||
const mapRoutes = useMemo(
|
||||
() =>
|
||||
@@ -133,6 +133,14 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
|
||||
if (
|
||||
(pathname.startsWith(`${ROUTES.LLM_OBSERVABILITY_BASE}/`) ||
|
||||
pathname === ROUTES.LLM_OBSERVABILITY_BASE) &&
|
||||
!isAIObservabilityEnabled
|
||||
) {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
|
||||
// Check for workspace access restriction (cloud only)
|
||||
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
|
||||
|
||||
@@ -212,14 +220,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for GET_STARTED → GET_STARTED_WITH_CLOUD redirect (feature flag)
|
||||
if (
|
||||
currentRoute?.path === ROUTES.GET_STARTED &&
|
||||
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
|
||||
) {
|
||||
return <Redirect to={ROUTES.GET_STARTED_WITH_CLOUD} />;
|
||||
}
|
||||
|
||||
// Main routing logic
|
||||
if (currentRoute) {
|
||||
const { isPrivate, key } = currentRoute;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ReactElement } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter, Route, Switch, useLocation } from 'react-router-dom';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -1263,80 +1262,6 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Get Started Route Redirect', () => {
|
||||
it('should redirect to GET_STARTED_WITH_CLOUD when on GET_STARTED and ONBOARDING_V3 feature flag is active', async () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.GET_STARTED,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING_V3,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
});
|
||||
|
||||
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is inactive', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.GET_STARTED,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING_V3,
|
||||
active: false,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
});
|
||||
|
||||
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is not present', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.GET_STARTED,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
featureFlags: [],
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
});
|
||||
|
||||
it('should not redirect when on different route even if ONBOARDING_V3 is active', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.HOME,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING_V3,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should not redirect while license is still being fetched', () => {
|
||||
renderPrivateRoute({
|
||||
@@ -1496,16 +1421,16 @@ describe('PrivateRoute', () => {
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
it('should allow EDITOR to access /get-started route', () => {
|
||||
it('should allow EDITOR to access /get-started-with-signoz-cloud route', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.GET_STARTED,
|
||||
initialRoute: ROUTES.GET_STARTED_WITH_CLOUD,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: USER_ROLES.EDITOR as ROLES }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -57,13 +57,6 @@ export const TraceFilter = Loadable(
|
||||
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
|
||||
);
|
||||
|
||||
export const TraceDetail = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "TraceDetail Page" */ 'pages/TraceDetailV2/index'
|
||||
),
|
||||
);
|
||||
|
||||
export const TraceDetailOldRedirect = Loadable(
|
||||
() =>
|
||||
import(
|
||||
@@ -90,14 +83,6 @@ export const SettingsPage = Loadable(
|
||||
() => import(/* webpackChunkName: "SettingsPage" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const GettingStarted = Loadable(
|
||||
() => import(/* webpackChunkName: "GettingStarted" */ 'pages/GettingStarted'),
|
||||
);
|
||||
|
||||
export const Onboarding = Loadable(
|
||||
() => import(/* webpackChunkName: "Onboarding" */ 'pages/OnboardingPage'),
|
||||
);
|
||||
|
||||
export const OrgOnboarding = Loadable(
|
||||
() => import(/* webpackChunkName: "OrgOnboarding" */ 'pages/OrgOnboarding'),
|
||||
);
|
||||
@@ -337,3 +322,17 @@ export const AIAssistantPage = Loadable(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityModelPricingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
IntegrationsDetailsPage,
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -33,7 +35,6 @@ import {
|
||||
MeterExplorerPage,
|
||||
MetricsExplorer,
|
||||
OldLogsExplorer,
|
||||
Onboarding,
|
||||
OnboardingV2,
|
||||
OrgOnboarding,
|
||||
PasswordReset,
|
||||
@@ -70,13 +71,6 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: false,
|
||||
key: 'SIGN_UP',
|
||||
},
|
||||
{
|
||||
path: ROUTES.GET_STARTED,
|
||||
exact: false,
|
||||
component: Onboarding,
|
||||
isPrivate: true,
|
||||
key: 'GET_STARTED',
|
||||
},
|
||||
{
|
||||
path: ROUTES.GET_STARTED_WITH_CLOUD,
|
||||
exact: false,
|
||||
@@ -477,6 +471,13 @@ const routes: AppRoutes[] = [
|
||||
key: 'METRICS_EXPLORER_VIEWS',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
exact: true,
|
||||
component: MetricsExplorer,
|
||||
key: 'METRICS_EXPLORER_VOLUME_CONTROL',
|
||||
isPrivate: true,
|
||||
},
|
||||
|
||||
{
|
||||
path: ROUTES.METER,
|
||||
@@ -513,6 +514,20 @@ const routes: AppRoutes[] = [
|
||||
key: 'AI_ASSISTANT',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_BASE',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
exact: true,
|
||||
component: LLMObservabilityModelPricingPage,
|
||||
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORT_ROUTE: AppRoutes = {
|
||||
|
||||
@@ -4,14 +4,22 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetChecks200,
|
||||
GetChecksParams,
|
||||
InframonitoringtypesPostableClustersDTO,
|
||||
InframonitoringtypesPostableDaemonSetsDTO,
|
||||
InframonitoringtypesPostableDeploymentsDTO,
|
||||
@@ -38,6 +46,93 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Checks whether the metrics and attributes required to power the infra-monitoring section selected by the 'type' query parameter (hosts, processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces, clusters, volumes) are being received. For each collector receiver or processor that contributes required metrics or attributes, lists what is present and what is missing, with a prebuilt user-facing message and a docs link per missing component. Default-enabled metrics are those expected as soon as the receiver is configured; optional metrics require 'enabled: true' in receiver config. 'ready' is true only when every missing list is empty.
|
||||
* @summary Run Infra Monitoring Setup Checks
|
||||
*/
|
||||
export const getChecks = (params: GetChecksParams, signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetChecks200>({
|
||||
url: `/api/v2/infra_monitoring/checks`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetChecksQueryKey = (params?: GetChecksParams) => {
|
||||
return [
|
||||
`/api/v2/infra_monitoring/checks`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetChecksQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getChecks>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetChecksParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetChecksQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getChecks>>> = ({
|
||||
signal,
|
||||
}) => getChecks(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getChecks>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetChecksQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getChecks>>
|
||||
>;
|
||||
export type GetChecksQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Run Infra Monitoring Setup Checks
|
||||
*/
|
||||
|
||||
export function useGetChecks<
|
||||
TData = Awaited<ReturnType<typeof getChecks>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetChecksParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetChecksQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Run Infra Monitoring Setup Checks
|
||||
*/
|
||||
export const invalidateGetChecks = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetChecksParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetChecksQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Clusters for Infra Monitoring
|
||||
|
||||
@@ -18,32 +18,776 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CreateMetricReductionRule201,
|
||||
DeleteMetricReductionRuleByIDPathParameters,
|
||||
GetMetricAlerts200,
|
||||
GetMetricAlertsParams,
|
||||
GetMetricAttributes200,
|
||||
GetMetricAttributesParams,
|
||||
GetMetricDashboards200,
|
||||
GetMetricDashboardsParams,
|
||||
GetMetricDashboardsV2200,
|
||||
GetMetricDashboardsV2Params,
|
||||
GetMetricHighlights200,
|
||||
GetMetricHighlightsParams,
|
||||
GetMetricMetadata200,
|
||||
GetMetricMetadataParams,
|
||||
GetMetricReductionRuleByID200,
|
||||
GetMetricReductionRuleByIDPathParameters,
|
||||
GetMetricReductionRuleStats200,
|
||||
GetMetricReductionRuleTimeseries200,
|
||||
GetMetricsOnboardingStatus200,
|
||||
GetMetricsStats200,
|
||||
GetMetricsTreemap200,
|
||||
InspectMetrics200,
|
||||
ListMetricReductionRules200,
|
||||
ListMetricReductionRulesParams,
|
||||
ListMetrics200,
|
||||
ListMetricsParams,
|
||||
MetricreductionruletypesPostableReductionRuleDTO,
|
||||
MetricreductionruletypesPostableReductionRulePreviewDTO,
|
||||
MetricreductionruletypesUpdatableReductionRuleDTO,
|
||||
MetricsexplorertypesInspectMetricsRequestDTO,
|
||||
MetricsexplorertypesStatsRequestDTO,
|
||||
MetricsexplorertypesTreemapRequestDTO,
|
||||
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
|
||||
PreviewMetricReductionRule200,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateMetricReductionRuleByID200,
|
||||
UpdateMetricReductionRuleByIDPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns active metric volume-control (label reduction) rules.
|
||||
* @summary List metric reduction rules
|
||||
*/
|
||||
export const listMetricReductionRules = (
|
||||
params?: ListMetricReductionRulesParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListMetricReductionRules200>({
|
||||
url: `/api/v2/metric_reduction_rules`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListMetricReductionRulesQueryKey = (
|
||||
params?: ListMetricReductionRulesParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v2/metric_reduction_rules`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getListMetricReductionRulesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListMetricReductionRulesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListMetricReductionRulesQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>
|
||||
> = ({ signal }) => listMetricReductionRules(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListMetricReductionRulesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>
|
||||
>;
|
||||
export type ListMetricReductionRulesQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List metric reduction rules
|
||||
*/
|
||||
|
||||
export function useListMetricReductionRules<
|
||||
TData = Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListMetricReductionRulesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListMetricReductionRulesQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List metric reduction rules
|
||||
*/
|
||||
export const invalidateListMetricReductionRules = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListMetricReductionRulesParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListMetricReductionRulesQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a volume-control rule for a metric and returns it with its id; fails if the metric already has a rule.
|
||||
* @summary Create a metric reduction rule
|
||||
*/
|
||||
export const createMetricReductionRule = (
|
||||
metricreductionruletypesPostableReductionRuleDTO?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateMetricReductionRule201>({
|
||||
url: `/api/v2/metric_reduction_rules`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: metricreductionruletypesPostableReductionRuleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateMetricReductionRuleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createMetricReductionRule'];
|
||||
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 createMetricReductionRule>>,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createMetricReductionRule(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateMetricReductionRuleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createMetricReductionRule>>
|
||||
>;
|
||||
export type CreateMetricReductionRuleMutationBody =
|
||||
| BodyType<MetricreductionruletypesPostableReductionRuleDTO>
|
||||
| undefined;
|
||||
export type CreateMetricReductionRuleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create a metric reduction rule
|
||||
*/
|
||||
export const useCreateMetricReductionRule = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateMetricReductionRuleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Deletes a volume-control rule by its id.
|
||||
* @summary Delete a metric reduction rule by id
|
||||
*/
|
||||
export const deleteMetricReductionRuleByID = (
|
||||
{ id }: DeleteMetricReductionRuleByIDPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/metric_reduction_rules/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteMetricReductionRuleByIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteMetricReductionRuleByID'];
|
||||
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 deleteMetricReductionRuleByID>>,
|
||||
{ pathParams: DeleteMetricReductionRuleByIDPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteMetricReductionRuleByID(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteMetricReductionRuleByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>
|
||||
>;
|
||||
|
||||
export type DeleteMetricReductionRuleByIDMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete a metric reduction rule by id
|
||||
*/
|
||||
export const useDeleteMetricReductionRuleByID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteMetricReductionRuleByIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a single volume-control rule by its id.
|
||||
* @summary Get a metric reduction rule by id
|
||||
*/
|
||||
export const getMetricReductionRuleByID = (
|
||||
{ id }: GetMetricReductionRuleByIDPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricReductionRuleByID200>({
|
||||
url: `/api/v2/metric_reduction_rules/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleByIDQueryKey = ({
|
||||
id,
|
||||
}: GetMetricReductionRuleByIDPathParameters) => {
|
||||
return [`/api/v2/metric_reduction_rules/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleByIDQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetMetricReductionRuleByIDPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricReductionRuleByIDQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleByID>>
|
||||
> = ({ signal }) => getMetricReductionRuleByID({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricReductionRuleByIDQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleByID>>
|
||||
>;
|
||||
export type GetMetricReductionRuleByIDQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get a metric reduction rule by id
|
||||
*/
|
||||
|
||||
export function useGetMetricReductionRuleByID<
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetMetricReductionRuleByIDPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricReductionRuleByIDQueryOptions(
|
||||
{ id },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get a metric reduction rule by id
|
||||
*/
|
||||
export const invalidateGetMetricReductionRuleByID = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetMetricReductionRuleByIDPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricReductionRuleByIDQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the match type and labels of a volume-control rule by its id; the metric name is immutable.
|
||||
* @summary Update a metric reduction rule by id
|
||||
*/
|
||||
export const updateMetricReductionRuleByID = (
|
||||
{ id }: UpdateMetricReductionRuleByIDPathParameters,
|
||||
metricreductionruletypesUpdatableReductionRuleDTO?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateMetricReductionRuleByID200>({
|
||||
url: `/api/v2/metric_reduction_rules/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: metricreductionruletypesUpdatableReductionRuleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateMetricReductionRuleByIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricReductionRuleByIDPathParameters;
|
||||
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricReductionRuleByIDPathParameters;
|
||||
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateMetricReductionRuleByID'];
|
||||
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 updateMetricReductionRuleByID>>,
|
||||
{
|
||||
pathParams: UpdateMetricReductionRuleByIDPathParameters;
|
||||
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateMetricReductionRuleByID(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateMetricReductionRuleByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>
|
||||
>;
|
||||
export type UpdateMetricReductionRuleByIDMutationBody =
|
||||
| BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>
|
||||
| undefined;
|
||||
export type UpdateMetricReductionRuleByIDMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update a metric reduction rule by id
|
||||
*/
|
||||
export const useUpdateMetricReductionRuleByID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricReductionRuleByIDPathParameters;
|
||||
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricReductionRuleByIDPathParameters;
|
||||
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateMetricReductionRuleByIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Estimates the series reduction and related-asset impact of a candidate volume-control rule without persisting it.
|
||||
* @summary Preview a metric reduction rule
|
||||
*/
|
||||
export const previewMetricReductionRule = (
|
||||
metricreductionruletypesPostableReductionRulePreviewDTO?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PreviewMetricReductionRule200>({
|
||||
url: `/api/v2/metric_reduction_rules/preview`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: metricreductionruletypesPostableReductionRulePreviewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPreviewMetricReductionRuleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['previewMetricReductionRule'];
|
||||
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 previewMetricReductionRule>>,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return previewMetricReductionRule(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PreviewMetricReductionRuleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>
|
||||
>;
|
||||
export type PreviewMetricReductionRuleMutationBody =
|
||||
| BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO>
|
||||
| undefined;
|
||||
export type PreviewMetricReductionRuleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Preview a metric reduction rule
|
||||
*/
|
||||
export const usePreviewMetricReductionRule = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPreviewMetricReductionRuleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns total ingested vs retained series and the estimated monthly savings across all volume-control rules.
|
||||
* @summary Metric reduction stats
|
||||
*/
|
||||
export const getMetricReductionRuleStats = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetMetricReductionRuleStats200>({
|
||||
url: `/api/v2/metric_reduction_rules/stats`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleStatsQueryKey = () => {
|
||||
return [`/api/v2/metric_reduction_rules/stats`] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleStatsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricReductionRuleStatsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleStats>>
|
||||
> = ({ signal }) => getMetricReductionRuleStats(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricReductionRuleStatsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleStats>>
|
||||
>;
|
||||
export type GetMetricReductionRuleStatsQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Metric reduction stats
|
||||
*/
|
||||
|
||||
export function useGetMetricReductionRuleStats<
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricReductionRuleStatsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Metric reduction stats
|
||||
*/
|
||||
export const invalidateGetMetricReductionRuleStats = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricReductionRuleStatsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns ingested vs retained series over time across all volume-control rules (hourly buckets), in the query-range time-series response shape.
|
||||
* @summary Metric reduction volume over time
|
||||
*/
|
||||
export const getMetricReductionRuleTimeseries = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetMetricReductionRuleTimeseries200>({
|
||||
url: `/api/v2/metric_reduction_rules/timeseries`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleTimeseriesQueryKey = () => {
|
||||
return [`/api/v2/metric_reduction_rules/timeseries`] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleTimeseriesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricReductionRuleTimeseriesQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>
|
||||
> = ({ signal }) => getMetricReductionRuleTimeseries(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricReductionRuleTimeseriesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>
|
||||
>;
|
||||
export type GetMetricReductionRuleTimeseriesQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Metric reduction volume over time
|
||||
*/
|
||||
|
||||
export function useGetMetricReductionRuleTimeseries<
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricReductionRuleTimeseriesQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Metric reduction volume over time
|
||||
*/
|
||||
export const invalidateGetMetricReductionRuleTimeseries = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricReductionRuleTimeseriesQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns a list of distinct metric names within the specified time range
|
||||
* @summary List metric names
|
||||
@@ -1045,3 +1789,100 @@ export const useGetMetricsTreemap = <
|
||||
> => {
|
||||
return useMutation(getGetMetricsTreemapMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns associated v2 dashboards for a specified metric
|
||||
* @summary Get metric dashboards (v2)
|
||||
*/
|
||||
export const getMetricDashboardsV2 = (
|
||||
params: GetMetricDashboardsV2Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricDashboardsV2200>({
|
||||
url: `/api/v3/metrics/dashboards`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsV2QueryKey = (
|
||||
params?: GetMetricDashboardsV2Params,
|
||||
) => {
|
||||
return [`/api/v3/metrics/dashboards`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricDashboardsV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricDashboardsV2QueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>
|
||||
> = ({ signal }) => getMetricDashboardsV2(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricDashboardsV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>
|
||||
>;
|
||||
export type GetMetricDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get metric dashboards (v2)
|
||||
*/
|
||||
|
||||
export function useGetMetricDashboardsV2<
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricDashboardsV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricDashboardsV2QueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get metric dashboards (v2)
|
||||
*/
|
||||
export const invalidateGetMetricDashboardsV2 = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetMetricDashboardsV2Params,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricDashboardsV2QueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,10 +30,8 @@ import type {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
TestSpanMappers200,
|
||||
UpdateSpanMapperGroupPathParameters,
|
||||
UpdateSpanMapperPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -782,86 +780,3 @@ export const useUpdateSpanMapper = <
|
||||
> => {
|
||||
return useMutation(getUpdateSpanMapperMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Tests how span mappers would transform sample spans
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const testSpanMappers = (
|
||||
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<TestSpanMappers200>({
|
||||
url: `/api/v1/span_mapper_groups/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableSpanMapperTestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestSpanMappersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testSpanMappers'];
|
||||
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 testSpanMappers>>,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testSpanMappers(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestSpanMappersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>
|
||||
>;
|
||||
export type TestSpanMappersMutationBody =
|
||||
| BodyType<SpantypesPostableSpanMapperTestDTO>
|
||||
| undefined;
|
||||
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const useTestSpanMappers = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestSpanMappersMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { omit } from 'lodash-es';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV2PayloadProps,
|
||||
GetTraceV2SuccessResponse,
|
||||
} from 'types/api/trace/getTraceV2';
|
||||
|
||||
const getTraceV2 = async (
|
||||
props: GetTraceV2PayloadProps,
|
||||
): Promise<SuccessResponse<GetTraceV2SuccessResponse> | ErrorResponse> => {
|
||||
let uncollapsedSpans = [...props.uncollapsedSpans];
|
||||
if (!props.isSelectedSpanIDUnCollapsed) {
|
||||
uncollapsedSpans = uncollapsedSpans.filter(
|
||||
(node) => node !== props.selectedSpanId,
|
||||
);
|
||||
}
|
||||
const postData: GetTraceV2PayloadProps = {
|
||||
...props,
|
||||
uncollapsedSpans,
|
||||
};
|
||||
const response = await axios.post<GetTraceV2SuccessResponse>(
|
||||
`/traces/waterfall/${props.traceId}`,
|
||||
omit(postData, 'traceId'),
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getTraceV2;
|
||||
@@ -41,6 +41,7 @@ const getTraceV4 = async (
|
||||
> & { spans: WireSpan[] | null };
|
||||
|
||||
// Derive 'service.name' from resource for convenience — only derived field
|
||||
// todo(tech-debt): to remove use of this and to directly use service.name from resources.
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
function FlamegraphImg(): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 3c1 3 2.5 3.5 3.5 4.5A5 5 0 0113 11a5 5 0 11-10 0c0-.3 0-.6.1-.9a2 2 0 103.3-2C4 5.5 7 3 8 3zM21 4h-8M20 14.5h-3M20 9.5h-3M21 20H4"
|
||||
stroke={isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlamegraphImg;
|
||||
@@ -21,6 +21,8 @@ interface ErrorInPlaceProps {
|
||||
width?: string | number;
|
||||
/** Custom content instead of ErrorContent */
|
||||
children?: ReactNode;
|
||||
/** Test ID for testing */
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,7 @@ function ErrorInPlace({
|
||||
height = '100%',
|
||||
width = '100%',
|
||||
children,
|
||||
'data-testid': dataTestId,
|
||||
}: ErrorInPlaceProps): JSX.Element {
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
@@ -59,7 +62,11 @@ function ErrorInPlace({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`error-in-place ${className}`.trim()} style={containerStyle}>
|
||||
<div
|
||||
className={`error-in-place ${className}`.trim()}
|
||||
style={containerStyle}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{children || <ErrorContent error={error} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
163
frontend/src/components/InviteMembers/InviteMembers.module.scss
Normal file
163
frontend/src/components/InviteMembers/InviteMembers.module.scss
Normal file
@@ -0,0 +1,163 @@
|
||||
.inviteMembers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.headerCellEmail {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerCellRole {
|
||||
flex: 0 0 160px;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.headerCellAction {
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cellEmail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
|
||||
--input-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-hover-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-focus-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-disabled-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
|
||||
input::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.cellRole {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
flex: 0 0 160px;
|
||||
width: 160px;
|
||||
|
||||
:global(.roles-single-select) {
|
||||
width: 100%;
|
||||
|
||||
:global(.ant-select-selector) {
|
||||
background-color: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cellAction {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
.addRow {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.callout {
|
||||
animation: shake 300ms ease-out;
|
||||
|
||||
&[data-type='success'] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.resultsList {
|
||||
margin: var(--spacing-2) 0 0 var(--spacing-8);
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
221
frontend/src/components/InviteMembers/InviteMembers.tsx
Normal file
221
frontend/src/components/InviteMembers/InviteMembers.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { CircleAlert, Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import RolesSelect from 'components/RolesSelect/RolesSelect';
|
||||
|
||||
import styles from './InviteMembers.module.scss';
|
||||
import { InviteMembersProps } from './types';
|
||||
import { useInviteMembers } from './useInviteMembers';
|
||||
|
||||
function InviteMembers({
|
||||
className,
|
||||
initialRowCount = 3,
|
||||
minRows = 1,
|
||||
emailPlaceholder = 'e.g. john@signoz.io',
|
||||
showHeader = true,
|
||||
showAddButton = true,
|
||||
onSuccess,
|
||||
onPartialSuccess,
|
||||
onAllFailed,
|
||||
renderFooter,
|
||||
}: InviteMembersProps): JSX.Element {
|
||||
const {
|
||||
rows,
|
||||
emailValidity,
|
||||
hasInvalidEmails,
|
||||
hasInvalidRoles,
|
||||
isSubmitting,
|
||||
inviteResults,
|
||||
addRow,
|
||||
removeRow,
|
||||
updateEmail,
|
||||
updateRole,
|
||||
reset,
|
||||
submit,
|
||||
touchedRows,
|
||||
failedResults,
|
||||
successResults,
|
||||
} = useInviteMembers({
|
||||
initialRowCount,
|
||||
onSuccess,
|
||||
onPartialSuccess,
|
||||
onAllFailed,
|
||||
});
|
||||
|
||||
const canSubmit = !isSubmitting && touchedRows.length > 0;
|
||||
const canRemoveRow = rows.length > minRows;
|
||||
|
||||
const getValidationErrorMessage = (): string => {
|
||||
if (hasInvalidEmails && hasInvalidRoles) {
|
||||
return 'Please enter valid emails and select roles for team members';
|
||||
}
|
||||
if (hasInvalidEmails) {
|
||||
return 'Please enter valid emails for team members';
|
||||
}
|
||||
return 'Please select roles for team members';
|
||||
};
|
||||
|
||||
const hasValidationErrors = hasInvalidEmails || hasInvalidRoles;
|
||||
const hasResults = inviteResults !== null;
|
||||
const hasFailures = failedResults.length > 0;
|
||||
const hasSuccesses = successResults.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cx(styles.inviteMembers, className)}>
|
||||
<div className={styles.table}>
|
||||
{showHeader && (
|
||||
<div className={styles.header}>
|
||||
<Typography.Text
|
||||
size="base"
|
||||
weight="semibold"
|
||||
className={styles.headerCellEmail}
|
||||
>
|
||||
Email address
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
size="base"
|
||||
weight="semibold"
|
||||
className={styles.headerCellRole}
|
||||
>
|
||||
Role
|
||||
</Typography.Text>
|
||||
<div className={styles.headerCellAction} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.rows}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className={styles.row}>
|
||||
<div className={styles.cellEmail}>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={emailPlaceholder}
|
||||
value={row.email}
|
||||
onChange={(e): void => updateEmail(row.id, e.target.value)}
|
||||
name={`invite-email-${row.id}`}
|
||||
autoComplete="email"
|
||||
data-testid={`invite-email-${row.id}`}
|
||||
/>
|
||||
{emailValidity[row.id] === false && row.email.trim() !== '' && (
|
||||
<Typography.Text size="small" className={styles.errorText}>
|
||||
Invalid email address
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.cellRole}>
|
||||
<RolesSelect
|
||||
mode="single"
|
||||
value={row.roleId || undefined}
|
||||
onChange={(roleId): void => updateRole(row.id, roleId)}
|
||||
placeholder="Select role"
|
||||
allowClear={false}
|
||||
id={`invite-role-${row.id}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.cellAction}>
|
||||
{canRemoveRow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
onClick={(): void => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
data-testid={`invite-remove-${row.id}`}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showAddButton && (
|
||||
<div className={styles.addRow}>
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={addRow}
|
||||
data-testid="invite-add-row"
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasValidationErrors && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className={styles.callout}
|
||||
data-testid="invite-validation-error"
|
||||
>
|
||||
{getValidationErrorMessage()}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{hasResults && hasFailures && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className={styles.callout}
|
||||
data-testid="invite-api-error"
|
||||
>
|
||||
<div className={styles.results}>
|
||||
{hasSuccesses && (
|
||||
<Typography.Text size="small">
|
||||
{successResults.length} invite(s) sent successfully.
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Typography.Text size="small">
|
||||
{failedResults.length} invite(s) failed:
|
||||
</Typography.Text>
|
||||
<ul className={styles.resultsList}>
|
||||
{failedResults.map((result) => (
|
||||
<li key={result.email}>
|
||||
<Typography.Text size="small">
|
||||
{result.email}: {result.error}
|
||||
</Typography.Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{hasResults && !hasFailures && hasSuccesses && (
|
||||
<Callout
|
||||
type="success"
|
||||
size="small"
|
||||
showIcon
|
||||
className={styles.callout}
|
||||
data-testid="invite-success"
|
||||
>
|
||||
<Typography.Text size="small">
|
||||
{successResults.length} invite(s) sent successfully!
|
||||
</Typography.Text>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{renderFooter?.({
|
||||
submit,
|
||||
reset,
|
||||
canSubmit,
|
||||
isSubmitting,
|
||||
touchedCount: touchedRows.length,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteMembers;
|
||||
@@ -0,0 +1,240 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import {
|
||||
CREATE_USER_ENDPOINT,
|
||||
createErrorHandler,
|
||||
createRolesHandler,
|
||||
createSuccessHandler,
|
||||
VALID_EMAIL,
|
||||
} from './testUtils';
|
||||
|
||||
describe('InviteMembers - Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('reset behavior', () => {
|
||||
it('clears all rows when reset is called', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={2}
|
||||
renderFooter={({ reset }): JSX.Element => (
|
||||
<button data-testid="reset-btn" onClick={reset}>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
|
||||
await user.click(screen.getByTestId('reset-btn'));
|
||||
|
||||
const resetInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
expect(resetInputs).toHaveLength(2);
|
||||
resetInputs.forEach((input) => {
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears results on reset', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit, reset }): JSX.Element => (
|
||||
<>
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
<button data-testid="reset-btn" onClick={reset}>
|
||||
Reset
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
await expect(
|
||||
screen.findByTestId('invite-success'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('reset-btn'));
|
||||
|
||||
expect(screen.queryByTestId('invite-success')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('results cleared on edit', () => {
|
||||
it('clears API error when email is edited', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(createErrorHandler('already_exists', 'User already exists'));
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
await expect(
|
||||
screen.findByTestId('invite-api-error'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.type(emailInputs[0], 'x');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears API error when role is changed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(createErrorHandler('already_exists', 'User already exists'));
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
await expect(
|
||||
screen.findByTestId('invite-api-error'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const viewerElements = screen.getAllByText('Viewer');
|
||||
await user.click(viewerElements[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty submission', () => {
|
||||
it('does not submit when no rows are touched', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
onSuccess={onSuccess}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitting state', () => {
|
||||
it('disables submit while submitting', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit, isSubmitting }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
const submitBtn = screen.getByTestId('submit-btn');
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Submitting...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitespace handling', () => {
|
||||
it('trims email whitespace before submission', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const calls: { email: string }[] = [];
|
||||
|
||||
server.use(
|
||||
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
calls.push(body);
|
||||
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], ' alice@signoz.io ');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].email).toBe('alice@signoz.io');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import { createRolesHandler, createSuccessHandler } from './testUtils';
|
||||
|
||||
describe('InviteMembers - Rendering', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders default initial row count of 3', () => {
|
||||
render(<InviteMembers />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
expect(emailInputs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders custom initial row count', () => {
|
||||
render(<InviteMembers initialRowCount={5} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
expect(emailInputs).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('renders header by default', () => {
|
||||
render(<InviteMembers />);
|
||||
|
||||
expect(screen.getByText('Email address')).toBeInTheDocument();
|
||||
expect(screen.getByText('Role')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides header when showHeader is false', () => {
|
||||
render(<InviteMembers showHeader={false} />);
|
||||
|
||||
expect(screen.queryByText('Email address')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Role')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders add button by default', () => {
|
||||
render(<InviteMembers />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /add another/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides add button when showAddButton is false', () => {
|
||||
render(<InviteMembers showAddButton={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /add another/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom email placeholder', () => {
|
||||
render(<InviteMembers emailPlaceholder="custom@placeholder.com" />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('custom@placeholder.com');
|
||||
expect(emailInputs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<InviteMembers className="custom-class" />);
|
||||
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders footer via renderFooter prop', () => {
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ canSubmit }): JSX.Element => (
|
||||
<button data-testid="custom-footer" disabled={!canSubmit}>
|
||||
Custom Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-footer')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('custom-footer')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders role select for each row', () => {
|
||||
render(<InviteMembers initialRowCount={2} />);
|
||||
|
||||
const roleSelects = screen.getAllByText('Select role');
|
||||
expect(roleSelects).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import { createRolesHandler, createSuccessHandler } from './testUtils';
|
||||
|
||||
describe('InviteMembers - Row Management', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('adds a row when "Add another" is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={2} />);
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('removes a row when trash button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={3} />);
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
expect(removeButtons).toHaveLength(3);
|
||||
|
||||
await user.click(removeButtons[0]);
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('respects minRows constraint when removing rows', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={2} minRows={2} />);
|
||||
|
||||
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
|
||||
0,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
expect(removeButtons).toHaveLength(3);
|
||||
|
||||
await user.click(removeButtons[0]);
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
|
||||
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot remove rows below minRows=1 default', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={2} />);
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
await user.click(removeButtons[0]);
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(1);
|
||||
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves data in other rows when removing one', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={3} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], 'first@signoz.io');
|
||||
await user.type(emailInputs[2], 'third@signoz.io');
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
await user.click(removeButtons[1]);
|
||||
|
||||
const remainingInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
expect(remainingInputs).toHaveLength(2);
|
||||
expect(remainingInputs[0]).toHaveValue('first@signoz.io');
|
||||
expect(remainingInputs[1]).toHaveValue('third@signoz.io');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import {
|
||||
CREATE_USER_ENDPOINT,
|
||||
createErrorHandler,
|
||||
createRolesHandler,
|
||||
createSuccessHandler,
|
||||
createTrackingHandler,
|
||||
VALID_EMAIL,
|
||||
} from './testUtils';
|
||||
|
||||
describe('InviteMembers - Submission', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
it('calls createUser API for each touched row', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const { handler, calls } = createTrackingHandler();
|
||||
server.use(handler);
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={3}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toMatchObject({
|
||||
email: 'alice@signoz.io',
|
||||
userRoles: [{ id: 'role-viewer' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('calls createUser API for multiple touched rows', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const { handler, calls } = createTrackingHandler();
|
||||
server.use(handler);
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={3}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.type(emailInputs[2], 'charlie@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
const adminOptions = await screen.findAllByText('Admin');
|
||||
await user.click(adminOptions[adminOptions.length - 1]);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(calls).toHaveLength(3);
|
||||
});
|
||||
|
||||
expect(calls[0]).toMatchObject({
|
||||
email: 'alice@signoz.io',
|
||||
userRoles: [{ id: 'role-viewer' }],
|
||||
});
|
||||
expect(calls[1]).toMatchObject({
|
||||
email: 'bob@signoz.io',
|
||||
userRoles: [{ id: 'role-editor' }],
|
||||
});
|
||||
expect(calls[2]).toMatchObject({
|
||||
email: 'charlie@signoz.io',
|
||||
userRoles: [{ id: 'role-admin' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('callbacks', () => {
|
||||
it('calls onSuccess when all invites succeed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
onSuccess={onSuccess}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onAllFailed when all invites fail', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onAllFailed = jest.fn();
|
||||
|
||||
server.use(createErrorHandler('already_exists', 'User already exists'));
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
onAllFailed={onAllFailed}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onAllFailed).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
email: VALID_EMAIL,
|
||||
success: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onPartialSuccess when some invites succeed and some fail', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onPartialSuccess = jest.fn();
|
||||
const onSuccess = jest.fn();
|
||||
const onAllFailed = jest.fn();
|
||||
const apiCalls: string[] = [];
|
||||
let callCount = 0;
|
||||
|
||||
server.use(
|
||||
createRolesHandler(),
|
||||
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
apiCalls.push(body.email);
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
|
||||
}
|
||||
return res(
|
||||
ctx.status(409),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'already_exists',
|
||||
message: 'User already exists',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={2}
|
||||
onSuccess={onSuccess}
|
||||
onPartialSuccess={onPartialSuccess}
|
||||
onAllFailed={onAllFailed}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(apiCalls).toStrictEqual(['alice@signoz.io', 'bob@signoz.io']);
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
expect(onAllFailed).not.toHaveBeenCalled();
|
||||
expect(onPartialSuccess).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ email: 'alice@signoz.io', success: true }),
|
||||
expect.objectContaining({
|
||||
email: 'bob@signoz.io',
|
||||
success: false,
|
||||
error: 'User already exists',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('invite-api-error'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('1 invite(s) sent successfully.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('1 invite(s) failed:')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('bob@signoz.io: User already exists'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('result display', () => {
|
||||
it('shows success callout when all invites succeed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('invite-success'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error callout with failed emails when API fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(createErrorHandler('already_exists', 'User already exists'));
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('invite-api-error'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText(/1 invite\(s\) failed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer props', () => {
|
||||
it('provides correct canSubmit state', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ canSubmit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" disabled={!canSubmit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('submit-btn')).toBeDisabled();
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
|
||||
expect(screen.getByTestId('submit-btn')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('provides touchedCount', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={3}
|
||||
renderFooter={({ touchedCount }): JSX.Element => (
|
||||
<span data-testid="touched-count">{touchedCount}</span>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('touched-count')).toHaveTextContent('0');
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], 'a@b.com');
|
||||
|
||||
expect(screen.getByTestId('touched-count')).toHaveTextContent('1');
|
||||
|
||||
await user.type(emailInputs[1], 'c@d.com');
|
||||
|
||||
expect(screen.getByTestId('touched-count')).toHaveTextContent('2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import {
|
||||
createRolesHandler,
|
||||
createSuccessHandler,
|
||||
INVALID_EMAIL,
|
||||
VALID_EMAIL,
|
||||
} from './testUtils';
|
||||
|
||||
describe('InviteMembers - Validation', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('email validation', () => {
|
||||
it('shows email validation error when email is invalid and role is selected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], INVALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please enter valid emails for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows inline error for invalid email field', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], INVALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Invalid email address'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears validation error when email is corrected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], INVALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please enter valid emails for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.clear(emailInputs[0]);
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText('Please enter valid emails for team members'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('role validation', () => {
|
||||
it('shows role validation error when role is missing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please select roles for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears role validation error when role is selected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please select roles for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText('Please select roles for team members'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined validation', () => {
|
||||
it('shows combined error when both email and role are invalid', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], INVALID_EMAIL);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText(
|
||||
'Please enter valid emails and select roles for team members',
|
||||
),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('touched rows', () => {
|
||||
it('only validates touched rows (rows with email or role)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={3}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('invite-validation-error'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
54
frontend/src/components/InviteMembers/__tests__/testUtils.ts
Normal file
54
frontend/src/components/InviteMembers/__tests__/testUtils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { RestHandler } from 'msw';
|
||||
import { rest } from 'mocks-server/server';
|
||||
|
||||
export const CREATE_USER_ENDPOINT = '*/api/v2/users';
|
||||
export const LIST_ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
export const MOCK_ROLES = [
|
||||
{ id: 'role-admin', name: 'Admin', description: 'Admin role' },
|
||||
{ id: 'role-editor', name: 'Editor', description: 'Editor role' },
|
||||
{ id: 'role-viewer', name: 'Viewer', description: 'Viewer role' },
|
||||
];
|
||||
|
||||
export const VALID_EMAIL = 'alice@signoz.io';
|
||||
export const INVALID_EMAIL = 'not-an-email';
|
||||
|
||||
export function createSuccessHandler(): RestHandler {
|
||||
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ data: { id: 'user-123' } })),
|
||||
);
|
||||
}
|
||||
|
||||
export function createErrorHandler(
|
||||
code: string,
|
||||
message: string,
|
||||
status = 409,
|
||||
): RestHandler {
|
||||
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(status),
|
||||
ctx.json({
|
||||
errors: [{ code, msg: message }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createRolesHandler(): RestHandler {
|
||||
return rest.get(LIST_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: MOCK_ROLES })),
|
||||
);
|
||||
}
|
||||
|
||||
export function createTrackingHandler(): {
|
||||
handler: RestHandler;
|
||||
calls: unknown[];
|
||||
} {
|
||||
const calls: unknown[] = [];
|
||||
const handler = rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
calls.push(body);
|
||||
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
|
||||
});
|
||||
return { handler, calls };
|
||||
}
|
||||
64
frontend/src/components/InviteMembers/types.ts
Normal file
64
frontend/src/components/InviteMembers/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export interface InviteMemberRow {
|
||||
id: string;
|
||||
email: string;
|
||||
roleId: string;
|
||||
}
|
||||
|
||||
export interface InviteResult {
|
||||
email: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FooterRenderProps {
|
||||
submit: () => Promise<InviteResult[]>;
|
||||
reset: () => void;
|
||||
canSubmit: boolean;
|
||||
isSubmitting: boolean;
|
||||
touchedCount: number;
|
||||
}
|
||||
|
||||
export interface UseInviteMembersOptions {
|
||||
initialRowCount?: number;
|
||||
onSuccess?: () => void;
|
||||
onPartialSuccess?: (results: InviteResult[]) => void;
|
||||
onAllFailed?: (results: InviteResult[]) => void;
|
||||
}
|
||||
|
||||
export interface UseInviteMembersReturn {
|
||||
rows: InviteMemberRow[];
|
||||
emailValidity: Record<string, boolean>;
|
||||
hasInvalidEmails: boolean;
|
||||
hasInvalidRoles: boolean;
|
||||
isSubmitting: boolean;
|
||||
inviteResults: InviteResult[] | null;
|
||||
|
||||
addRow: () => void;
|
||||
removeRow: (id: string) => void;
|
||||
updateEmail: (id: string, email: string) => void;
|
||||
updateRole: (id: string, roleId: string | undefined) => void;
|
||||
reset: () => void;
|
||||
submit: () => Promise<InviteResult[]>;
|
||||
|
||||
touchedRows: InviteMemberRow[];
|
||||
failedResults: InviteResult[];
|
||||
successResults: InviteResult[];
|
||||
canSubmit: boolean;
|
||||
}
|
||||
|
||||
export interface InviteMembersProps {
|
||||
className?: string;
|
||||
initialRowCount?: number;
|
||||
minRows?: number;
|
||||
emailPlaceholder?: string;
|
||||
showHeader?: boolean;
|
||||
showAddButton?: boolean;
|
||||
|
||||
onSuccess?: () => void;
|
||||
onPartialSuccess?: (results: InviteResult[]) => void;
|
||||
onAllFailed?: (results: InviteResult[]) => void;
|
||||
|
||||
renderFooter?: (props: FooterRenderProps) => ReactNode;
|
||||
}
|
||||
245
frontend/src/components/InviteMembers/useInviteMembers.ts
Normal file
245
frontend/src/components/InviteMembers/useInviteMembers.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { AxiosError } from 'axios';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { createUser } from 'api/generated/services/users';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
InviteMemberRow,
|
||||
InviteResult,
|
||||
UseInviteMembersOptions,
|
||||
UseInviteMembersReturn,
|
||||
} from './types';
|
||||
|
||||
const createEmptyRow = (): InviteMemberRow => ({
|
||||
id: uuid(),
|
||||
email: '',
|
||||
roleId: '',
|
||||
});
|
||||
|
||||
const isRowTouched = (row: InviteMemberRow): boolean =>
|
||||
row.email.trim() !== '' || row.roleId !== '';
|
||||
|
||||
export function useInviteMembers(
|
||||
options: UseInviteMembersOptions = {},
|
||||
): UseInviteMembersReturn {
|
||||
const {
|
||||
initialRowCount = 3,
|
||||
onSuccess,
|
||||
onPartialSuccess,
|
||||
onAllFailed,
|
||||
} = options;
|
||||
|
||||
const [rows, setRows] = useState<InviteMemberRow[]>(() =>
|
||||
Array.from({ length: initialRowCount }, () => createEmptyRow()),
|
||||
);
|
||||
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [hasInvalidEmails, setHasInvalidEmails] = useState(false);
|
||||
const [hasInvalidRoles, setHasInvalidRoles] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [inviteResults, setInviteResults] = useState<InviteResult[] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const touchedRows = useMemo(() => rows.filter(isRowTouched), [rows]);
|
||||
|
||||
const failedResults = useMemo(
|
||||
() => inviteResults?.filter((r) => !r.success) ?? [],
|
||||
[inviteResults],
|
||||
);
|
||||
|
||||
const successResults = useMemo(
|
||||
() => inviteResults?.filter((r) => r.success) ?? [],
|
||||
[inviteResults],
|
||||
);
|
||||
|
||||
const debouncedValidateEmail = useMemo(
|
||||
() =>
|
||||
debounce((email: string, rowId: string) => {
|
||||
const isValid = EMAIL_REGEX.test(email);
|
||||
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
const validateAllRows = useCallback((): boolean => {
|
||||
let isValid = true;
|
||||
let hasEmailErrors = false;
|
||||
let hasRoleErrors = false;
|
||||
const updatedEmailValidity: Record<string, boolean> = {};
|
||||
|
||||
const touched = rows.filter(isRowTouched);
|
||||
|
||||
touched.forEach((row) => {
|
||||
const emailValid = EMAIL_REGEX.test(row.email);
|
||||
const roleValid = row.roleId !== '';
|
||||
|
||||
if (!emailValid || !row.email) {
|
||||
isValid = false;
|
||||
hasEmailErrors = true;
|
||||
}
|
||||
if (!roleValid) {
|
||||
isValid = false;
|
||||
hasRoleErrors = true;
|
||||
}
|
||||
|
||||
updatedEmailValidity[row.id] = emailValid;
|
||||
});
|
||||
|
||||
setEmailValidity(updatedEmailValidity);
|
||||
setHasInvalidEmails(hasEmailErrors);
|
||||
setHasInvalidRoles(hasRoleErrors);
|
||||
|
||||
return isValid;
|
||||
}, [rows]);
|
||||
|
||||
const addRow = useCallback((): void => {
|
||||
setRows((prev) => [...prev, createEmptyRow()]);
|
||||
}, []);
|
||||
|
||||
const removeRow = useCallback((id: string): void => {
|
||||
setRows((prev) => prev.filter((r) => r.id !== id));
|
||||
setEmailValidity((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[id];
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateEmail = useCallback(
|
||||
(id: string, email: string): void => {
|
||||
setRows((prev) => {
|
||||
const updated = cloneDeep(prev);
|
||||
const row = updated.find((r) => r.id === id);
|
||||
if (row) {
|
||||
row.email = email;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (hasInvalidEmails) {
|
||||
setHasInvalidEmails(false);
|
||||
}
|
||||
if (emailValidity[id] === false) {
|
||||
setEmailValidity((prev) => ({ ...prev, [id]: true }));
|
||||
}
|
||||
if (inviteResults) {
|
||||
setInviteResults(null);
|
||||
}
|
||||
|
||||
debouncedValidateEmail(email, id);
|
||||
},
|
||||
[hasInvalidEmails, emailValidity, inviteResults, debouncedValidateEmail],
|
||||
);
|
||||
|
||||
const updateRole = useCallback(
|
||||
(id: string, roleId: string | undefined): void => {
|
||||
setRows((prev) => {
|
||||
const updated = cloneDeep(prev);
|
||||
const row = updated.find((r) => r.id === id);
|
||||
if (row) {
|
||||
row.roleId = roleId ?? '';
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (hasInvalidRoles) {
|
||||
setHasInvalidRoles(false);
|
||||
}
|
||||
if (inviteResults) {
|
||||
setInviteResults(null);
|
||||
}
|
||||
},
|
||||
[hasInvalidRoles, inviteResults],
|
||||
);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setRows(Array.from({ length: initialRowCount }, () => createEmptyRow()));
|
||||
setEmailValidity({});
|
||||
setHasInvalidEmails(false);
|
||||
setHasInvalidRoles(false);
|
||||
setInviteResults(null);
|
||||
}, [initialRowCount]);
|
||||
|
||||
const submit = useCallback(async (): Promise<InviteResult[]> => {
|
||||
if (!validateAllRows()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const touched = rows.filter(isRowTouched);
|
||||
if (touched.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setInviteResults(null);
|
||||
|
||||
const results: InviteResult[] = [];
|
||||
|
||||
for (const row of touched) {
|
||||
try {
|
||||
await createUser({
|
||||
email: row.email.trim(),
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
userRoles: [{ id: row.roleId }],
|
||||
});
|
||||
results.push({ email: row.email, success: true });
|
||||
} catch (err) {
|
||||
const apiErr = convertToApiError(err as AxiosError<RenderErrorResponseDTO>);
|
||||
results.push({
|
||||
email: row.email,
|
||||
success: false,
|
||||
error: apiErr?.getErrorMessage() ?? 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setInviteResults(results);
|
||||
setIsSubmitting(false);
|
||||
|
||||
const failures = results.filter((r) => !r.success);
|
||||
const successes = results.filter((r) => r.success);
|
||||
|
||||
if (failures.length === 0) {
|
||||
onSuccess?.();
|
||||
} else if (successes.length > 0) {
|
||||
onPartialSuccess?.(results);
|
||||
} else {
|
||||
onAllFailed?.(results);
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [validateAllRows, rows, onSuccess, onPartialSuccess, onAllFailed]);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => !isSubmitting && touchedRows.length > 0,
|
||||
[isSubmitting, touchedRows.length],
|
||||
);
|
||||
|
||||
return {
|
||||
rows,
|
||||
emailValidity,
|
||||
hasInvalidEmails,
|
||||
hasInvalidRoles,
|
||||
isSubmitting,
|
||||
inviteResults,
|
||||
|
||||
addRow,
|
||||
removeRow,
|
||||
updateEmail,
|
||||
updateRole,
|
||||
reset,
|
||||
submit,
|
||||
|
||||
touchedRows,
|
||||
failedResults,
|
||||
successResults,
|
||||
canSubmit,
|
||||
};
|
||||
}
|
||||
@@ -62,13 +62,13 @@ function ErrorTitleAndKey({
|
||||
|
||||
switch (parentTitle) {
|
||||
case 'Consumers':
|
||||
link = `${ROUTES.GET_STARTED_APPLICATION_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Consumers}`;
|
||||
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Consumers}`;
|
||||
break;
|
||||
case 'Producers':
|
||||
link = `${ROUTES.GET_STARTED_APPLICATION_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Producers}`;
|
||||
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Producers}`;
|
||||
break;
|
||||
case 'Kafka':
|
||||
link = `${ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Kafka}`;
|
||||
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Kafka}`;
|
||||
break;
|
||||
default:
|
||||
link = '';
|
||||
|
||||
@@ -5,13 +5,9 @@ describe('PermissionDeniedFullPage', () => {
|
||||
it('renders the title and subtitle with the permissionName interpolated', () => {
|
||||
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Uh-oh! You don't have permission to view this page."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Uh-oh! You are not authorized')).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Please ask your SigNoz administrator to grant access/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with a different permissionName', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CircleSlash2 } from '@signozhq/icons';
|
||||
|
||||
import styles from './PermissionDeniedFullPage.module.scss';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
interface PermissionDeniedFullPageProps {
|
||||
permissionName: string;
|
||||
@@ -10,18 +11,18 @@ interface PermissionDeniedFullPageProps {
|
||||
function PermissionDeniedFullPage({
|
||||
permissionName,
|
||||
}: PermissionDeniedFullPageProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<span className={styles.icon}>
|
||||
<CircleSlash2 color={Style.CALLOUT_WARNING_TITLE} size={14} />
|
||||
</span>
|
||||
<p className={styles.title}>
|
||||
Uh-oh! You don't have permission to view this page.
|
||||
</p>
|
||||
<p className={styles.title}>Uh-oh! You are not authorized</p>
|
||||
<p className={styles.subtitle}>
|
||||
You need <code className={styles.permission}>{permissionName}</code> to
|
||||
view this page. Please ask your SigNoz administrator to grant access.
|
||||
<code className={styles.permission}>user/{user.id}</code> is not authorized
|
||||
to perform <code className={styles.permission}>{permissionName}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { LockKeyhole } from '@signozhq/icons';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Check, Copy, LockKeyhole } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
@@ -46,6 +48,23 @@ function OverviewTab({
|
||||
saveErrors = [],
|
||||
}: OverviewTabProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const [hasCopiedId, setHasCopiedId] = useState(false);
|
||||
|
||||
const handleCopyId = useCallback((): void => {
|
||||
if (account.id) {
|
||||
copyToClipboard(account.id);
|
||||
setHasCopiedId(true);
|
||||
}
|
||||
}, [account.id, copyToClipboard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCopiedId) {
|
||||
const timer = setTimeout(() => setHasCopiedId(false), 2000);
|
||||
return (): void => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [hasCopiedId]);
|
||||
|
||||
const formatTimestamp = useCallback(
|
||||
(ts: string | null | undefined): string => {
|
||||
@@ -93,6 +112,17 @@ function OverviewTab({
|
||||
</label>
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{account.id || '—'}</span>
|
||||
{account.id && (
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
onClick={handleCopyId}
|
||||
className="sa-drawer__copy-btn"
|
||||
data-testid="copy-id-btn"
|
||||
>
|
||||
{hasCopiedId ? <Check size={14} /> : <Copy size={14} />}
|
||||
</Button>
|
||||
)}
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -203,6 +203,19 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
color: var(--foreground);
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__disabled-roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { GuardAuthZ } from 'components/GuardAuthZ/GuardAuthZ';
|
||||
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
@@ -477,15 +476,9 @@ function ServiceAccountDrawer({
|
||||
!isAccountLoading &&
|
||||
!isAccountError &&
|
||||
selectedAccountId && (
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object={`serviceaccount:${selectedAccountId}`}
|
||||
fallbackOnNoPermissions={(): JSX.Element => (
|
||||
<PermissionDeniedCallout permissionName="serviceaccount:read" />
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview && account && (
|
||||
<>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview &&
|
||||
(canRead && account ? (
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
@@ -504,23 +497,24 @@ function ServiceAccountDrawer({
|
||||
onRefetchRoles={refetchRoles}
|
||||
saveErrors={saveErrors}
|
||||
/>
|
||||
)}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys &&
|
||||
(canListKeys ? (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDeleted}
|
||||
canUpdate={canUpdate}
|
||||
accountId={selectedAccountId}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
) : (
|
||||
<PermissionDeniedCallout permissionName="factor-api-key:list" />
|
||||
))}
|
||||
</>
|
||||
</GuardAuthZ>
|
||||
) : (
|
||||
<PermissionDeniedCallout permissionName="serviceaccount:read" />
|
||||
))}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys &&
|
||||
(canListKeys ? (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDeleted}
|
||||
canUpdate={canUpdate}
|
||||
accountId={selectedAccountId}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
) : (
|
||||
<PermissionDeniedCallout permissionName="factor-api-key:list" />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
.span-hover-card {
|
||||
.ant-popover-inner {
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 32%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
|
||||
);
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 32%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
|
||||
);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 4px;
|
||||
z-index: -1;
|
||||
will-change: background-color, backdrop-filter;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__operation {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.48px;
|
||||
}
|
||||
|
||||
&__service {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--danger-background);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__relative-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
gap: 8px;
|
||||
border-radius: 1px 0 0 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsla(358, 75%, 59%, 0.2) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
|
||||
&-icon {
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--danger-background);
|
||||
}
|
||||
}
|
||||
|
||||
&__relative-text {
|
||||
color: var(--bg-cherry-300);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Popover } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './SpanHoverCard.styles.scss';
|
||||
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
interface SpanHoverCardProps {
|
||||
span: Span;
|
||||
traceMetadata: ITraceMetadata;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SpanHoverCard({
|
||||
span,
|
||||
traceMetadata,
|
||||
children,
|
||||
}: SpanHoverCardProps): JSX.Element {
|
||||
const duration = span.durationNano / 1e6; // Convert nanoseconds to milliseconds
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(duration);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// Calculate relative start time from trace start
|
||||
const relativeStartTime = span.timestamp - traceMetadata.startTime;
|
||||
const { time: relativeTime, timeUnitName: relativeTimeUnit } =
|
||||
convertTimeToRelevantUnit(relativeStartTime);
|
||||
|
||||
// Format absolute start time
|
||||
const startTimeFormatted = dayjs(span.timestamp)
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS);
|
||||
|
||||
const getContent = (): JSX.Element => (
|
||||
<div className="span-hover-card">
|
||||
<div className="span-hover-card__row">
|
||||
<Typography.Text className="span-hover-card__label">
|
||||
Duration:
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-hover-card__value">
|
||||
{toFixed(formattedDuration, 2)}
|
||||
{timeUnitName}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="span-hover-card__row">
|
||||
<Typography.Text className="span-hover-card__label">
|
||||
Events:
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-hover-card__value">
|
||||
{span.event?.length || 0}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="span-hover-card__row">
|
||||
<Typography.Text className="span-hover-card__label">
|
||||
Start time:
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-hover-card__value">
|
||||
{startTimeFormatted}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="span-hover-card__relative-time">
|
||||
<div className="span-hover-card__relative-time-icon" />
|
||||
<Typography.Text className="span-hover-card__relative-text">
|
||||
{toFixed(relativeTime, 2)}
|
||||
{relativeTimeUnit} after trace start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
title={
|
||||
<div className="span-hover-card__title">
|
||||
<Typography.Text className="span-hover-card__operation">
|
||||
{span.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
mouseEnterDelay={0.2}
|
||||
content={getContent()}
|
||||
trigger="hover"
|
||||
rootClassName="span-hover-card"
|
||||
autoAdjustOverflow
|
||||
arrow={false}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanHoverCard;
|
||||
@@ -1,291 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { TimezoneContextType } from 'providers/Timezone';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import SpanHoverCard from '../SpanHoverCard';
|
||||
|
||||
// Mock timezone provider so SpanHoverCard can use useTimezone without a real context
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
__esModule: true,
|
||||
useTimezone: (): TimezoneContextType => ({
|
||||
timezone: {
|
||||
name: 'Coordinated Universal Time — UTC, GMT',
|
||||
value: 'UTC',
|
||||
offset: 'UTC',
|
||||
searchIndex: 'UTC',
|
||||
},
|
||||
browserTimezone: {
|
||||
name: 'Coordinated Universal Time — UTC, GMT',
|
||||
value: 'UTC',
|
||||
offset: 'UTC',
|
||||
searchIndex: 'UTC',
|
||||
},
|
||||
updateTimezone: jest.fn(),
|
||||
formatTimezoneAdjustedTimestamp: jest.fn(() => 'mock-date'),
|
||||
isAdaptationEnabled: true,
|
||||
setIsAdaptationEnabled: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock dayjs for testing, including timezone helpers used in timezoneUtils
|
||||
jest.mock('dayjs', () => {
|
||||
const mockDayjsInstance: any = {};
|
||||
|
||||
mockDayjsInstance.format = jest.fn((formatString: string) =>
|
||||
// Match the DD_MMM_YYYY_HH_MM_SS format: 'DD MMM YYYY, HH:mm:ss'
|
||||
formatString === 'DD MMM YYYY, HH:mm:ss'
|
||||
? '15 Mar 2024, 14:23:45'
|
||||
: 'mock-date',
|
||||
);
|
||||
|
||||
// Support chaining: dayjs().tz(timezone).format(...) and dayjs().tz(timezone).utcOffset()
|
||||
mockDayjsInstance.tz = jest.fn(() => mockDayjsInstance);
|
||||
mockDayjsInstance.utcOffset = jest.fn(() => 0);
|
||||
|
||||
const mockDayjs = jest.fn(() => mockDayjsInstance);
|
||||
|
||||
Object.assign(mockDayjs, {
|
||||
extend: jest.fn(),
|
||||
// Support dayjs.tz.guess()
|
||||
tz: { guess: jest.fn(() => 'UTC') },
|
||||
});
|
||||
|
||||
return mockDayjs;
|
||||
});
|
||||
|
||||
const HOVER_ELEMENT_ID = 'hover-element';
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
rootSpanId: 'root-span-id',
|
||||
parentSpanId: 'parent-span-id',
|
||||
name: 'GET /api/users',
|
||||
timestamp: 1679748225000000,
|
||||
durationNano: 150000000,
|
||||
serviceName: 'user-service',
|
||||
kind: 1,
|
||||
hasError: false,
|
||||
level: 1,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [
|
||||
{
|
||||
name: 'event1',
|
||||
timeUnixNano: 1679748225100000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
name: 'event2',
|
||||
timeUnixNano: 1679748225200000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
rootName: 'root-span',
|
||||
statusMessage: '',
|
||||
statusCodeString: 'OK',
|
||||
spanKind: 'server',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 1,
|
||||
};
|
||||
|
||||
const mockTraceMetadata = {
|
||||
startTime: 1679748225000000,
|
||||
endTime: 1679748226000000,
|
||||
};
|
||||
|
||||
describe('SpanHoverCard', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders child element correctly', () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid="child-element">Hover me</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('child-element')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hover me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows popover after 0.2 second delay on hover', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Hover for details</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover over the element
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
|
||||
// Popover should NOT appear immediately
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
|
||||
// Advance time by 0.5 seconds
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
// Now popover should appear
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show popover if hover is too brief', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Quick hover test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Quick hover and unhover (less than the 0.2s delay)
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100); // Only 0.1 seconds
|
||||
});
|
||||
fireEvent.mouseLeave(hoverElement);
|
||||
|
||||
// Advance past the full delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
// Popover should not appear
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays span information in popover content after delay', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Test span</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Check that popover shows span operation name in title
|
||||
expect(screen.getByText('GET /api/users')).toBeInTheDocument();
|
||||
|
||||
// Check duration information
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
expect(screen.getByText('150ms')).toBeInTheDocument();
|
||||
|
||||
// Check events count
|
||||
expect(screen.getByText('Events:')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
|
||||
// Check start time label
|
||||
expect(screen.getByText('Start time:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays date in DD MMM YYYY, HH:mm:ss format with seconds', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Date format test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Verify the DD MMM YYYY, HH:mm:ss format is displayed
|
||||
expect(screen.getByText('15 Mar 2024, 14:23:45')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays relative time information', async () => {
|
||||
const spanWithRelativeTime: Span = {
|
||||
...mockSpan,
|
||||
timestamp: mockTraceMetadata.startTime + 1000000, // 1 second later
|
||||
};
|
||||
|
||||
render(
|
||||
<SpanHoverCard span={spanWithRelativeTime} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Relative time test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Check relative time display
|
||||
expect(screen.getByText(/after trace start/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles spans with no events correctly', async () => {
|
||||
const spanWithoutEvents: Span = {
|
||||
...mockSpan,
|
||||
event: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<SpanHoverCard span={spanWithoutEvents} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>No events test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Events:')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('verifies mouseEnterDelay prop is set to 0.5', () => {
|
||||
const { container } = render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Delay test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
// The mouseEnterDelay prop should be set on the Popover component
|
||||
// This test verifies the implementation includes the delay
|
||||
const popover = container.querySelector('.ant-popover');
|
||||
expect(popover).not.toBeInTheDocument(); // Initially not visible
|
||||
|
||||
// Hover to trigger delay mechanism
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
|
||||
// Should not appear before delay
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
|
||||
// Should appear after delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,10 @@ export enum FeatureKeys {
|
||||
GATEWAY = 'gateway',
|
||||
PREMIUM_SUPPORT = 'premium_support',
|
||||
ANOMALY_DETECTION = 'anomaly_detection',
|
||||
ONBOARDING_V3 = 'onboarding_v3',
|
||||
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_DASHBOARD_V2 = 'use_dashboard_v2',
|
||||
EMABLE_AI_OBSERVABILITY = 'enable_ai_observability',
|
||||
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
|
||||
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
|
||||
}
|
||||
|
||||
@@ -11,14 +11,7 @@ const ROUTES = {
|
||||
TRACE_DETAIL_OLD: '/trace-old/:id',
|
||||
TRACES_EXPLORER: '/traces-explorer',
|
||||
ONBOARDING: '/onboarding',
|
||||
GET_STARTED: '/get-started',
|
||||
GET_STARTED_WITH_CLOUD: '/get-started-with-signoz-cloud',
|
||||
GET_STARTED_APPLICATION_MONITORING: '/get-started/application-monitoring',
|
||||
GET_STARTED_LOGS_MANAGEMENT: '/get-started/logs-management',
|
||||
GET_STARTED_INFRASTRUCTURE_MONITORING:
|
||||
'/get-started/infrastructure-monitoring',
|
||||
GET_STARTED_AWS_MONITORING: '/get-started/aws-monitoring',
|
||||
GET_STARTED_AZURE_MONITORING: '/get-started/azure-monitoring',
|
||||
USAGE_EXPLORER: '/usage-explorer',
|
||||
APPLICATION: '/services',
|
||||
ALL_DASHBOARD: '/dashboard',
|
||||
@@ -56,7 +49,9 @@ const ROUTES = {
|
||||
TRACE_EXPLORER: '/trace-explorer',
|
||||
BILLING: '/settings/billing',
|
||||
ROLES_SETTINGS: '/settings/roles',
|
||||
ROLE_CREATE: '/settings/roles/new',
|
||||
ROLE_DETAILS: '/settings/roles/:roleId',
|
||||
ROLE_EDIT: '/settings/roles/:roleId/edit',
|
||||
MEMBERS_SETTINGS: '/settings/members',
|
||||
SUPPORT: '/support',
|
||||
LOGS_SAVE_VIEWS: '/logs/saved-views',
|
||||
@@ -79,6 +74,7 @@ const ROUTES = {
|
||||
METRICS_EXPLORER: '/metrics-explorer/summary',
|
||||
METRICS_EXPLORER_EXPLORER: '/metrics-explorer/explorer',
|
||||
METRICS_EXPLORER_VIEWS: '/metrics-explorer/views',
|
||||
METRICS_EXPLORER_VOLUME_CONTROL: '/metrics-explorer/volume-control',
|
||||
API_MONITORING_BASE: '/api-monitoring',
|
||||
API_MONITORING: '/api-monitoring/explorer',
|
||||
METRICS_EXPLORER_BASE: '/metrics-explorer',
|
||||
@@ -93,6 +89,8 @@ const ROUTES = {
|
||||
AI_ASSISTANT_BASE: '/ai-assistant',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_BASE: '/llm-observability',
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -48,6 +48,48 @@ describe('getAutoContexts', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes the query in alert edit context', () => {
|
||||
const ruleId = 'rule-edit';
|
||||
const query = { queryType: 'builder', builder: { queryData: [] } };
|
||||
const compositeQuery = encodeURIComponent(JSON.stringify(query));
|
||||
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.compositeQuery}=${compositeQuery}`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.EDIT_ALERTS, search);
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: {
|
||||
page: 'alert_edit',
|
||||
ruleId,
|
||||
query,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes the query in alert new context (no ruleId)', () => {
|
||||
const query = { queryType: 'builder', builder: { queryData: [] } };
|
||||
const compositeQuery = encodeURIComponent(JSON.stringify(query));
|
||||
const search = `?${QueryParams.compositeQuery}=${compositeQuery}`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.ALERTS_NEW, search);
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: {
|
||||
page: 'alert_new',
|
||||
query,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns triggered alerts context on alert history without ruleId', () => {
|
||||
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, '');
|
||||
|
||||
|
||||
@@ -377,9 +377,63 @@
|
||||
}
|
||||
|
||||
.contextPopoverEmpty {
|
||||
// Fill the entity panel so the state sits centred in the dead space rather
|
||||
// than clinging to the top-left corner.
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 24px 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--l3-foreground);
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.contextPopoverEmptyIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
// `--empty-accent` is set per category on the root (robin/cherry/forest).
|
||||
color: var(--empty-accent);
|
||||
background: color-mix(in srgb, var(--empty-accent), transparent 88%);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.contextPopoverEmptyTitle {
|
||||
margin: 0;
|
||||
max-width: 280px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
// Clamp to 2 lines with an ellipsis so a long query can't blow out the
|
||||
// popover height. The CTA below is a stock DS link button, so the query is
|
||||
// kept readable here rather than forcing the button to wrap.
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.contextPopoverEmptyCta {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.contextPopoverEmptyCtaLabel {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.micBtn {
|
||||
|
||||
@@ -42,19 +42,22 @@ import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
|
||||
import { MessageAttachment } from '../../types';
|
||||
import { MessageContext } from '../../../../api/ai-assistant/chat';
|
||||
import {
|
||||
Bell,
|
||||
LayoutDashboard,
|
||||
Mic,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import styles from './ChatInput.module.scss';
|
||||
import ContextPickerEmptyState from './ContextPickerEmptyState';
|
||||
import {
|
||||
CONTEXT_CATEGORIES,
|
||||
CONTEXT_CATEGORY_ICONS,
|
||||
ContextCategory,
|
||||
} from './contextPicker';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (
|
||||
@@ -162,10 +165,6 @@ const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
|
||||
/** sessionStorage key for the "voice input failed this tab" flag. */
|
||||
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
|
||||
|
||||
const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
|
||||
|
||||
type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
|
||||
|
||||
interface SelectedContextItem {
|
||||
category: ContextCategory;
|
||||
entityId: string;
|
||||
@@ -205,12 +204,6 @@ interface ContextEntityItem {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const CONTEXT_CATEGORY_ICONS = {
|
||||
Dashboards: LayoutDashboard,
|
||||
Alerts: Bell,
|
||||
Services: ShieldCheck,
|
||||
} satisfies Record<ContextCategory, unknown>;
|
||||
|
||||
function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -331,6 +324,30 @@ export default function ChatInput({
|
||||
[mentionRange, selectedContexts, text],
|
||||
);
|
||||
|
||||
// Empty-state CTA: drop a starter prompt into the composer (never auto-sent)
|
||||
// and hand the user the caret at the end so they can finish the sentence.
|
||||
const handleContextPrefill = useCallback(
|
||||
(prompt: string) => {
|
||||
const next = capText(prompt);
|
||||
setText(next);
|
||||
committedTextRef.current = next;
|
||||
setMentionRange(null);
|
||||
setPickerSearchQuery('');
|
||||
setIsContextPickerOpen(false);
|
||||
// Defer so React commits the new value before we place the caret.
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.focus();
|
||||
const end = el.value.length;
|
||||
el.setSelectionRange(end, end);
|
||||
});
|
||||
},
|
||||
[capText],
|
||||
);
|
||||
|
||||
const focusCategory = useCallback((category: ContextCategory) => {
|
||||
setActiveContextCategory(category);
|
||||
setPickerSearchQuery('');
|
||||
@@ -824,10 +841,14 @@ export default function ChatInput({
|
||||
// Type-ahead filter against the `@<query>` typed in the textarea. When
|
||||
// the picker was opened from the "Add Context" button there's no
|
||||
// mention query, so fall back to the in-popover search input.
|
||||
const mentionQuery = mentionRange
|
||||
? text.slice(mentionRange.start + 1, mentionRange.end).toLowerCase()
|
||||
const rawMentionQuery = mentionRange
|
||||
? text.slice(mentionRange.start + 1, mentionRange.end)
|
||||
: '';
|
||||
const mentionQuery = rawMentionQuery.toLowerCase();
|
||||
const activeQuery = mentionQuery || pickerSearchQuery.trim().toLowerCase();
|
||||
// Original-case query for empty-state copy + prefill ("checkout", not the
|
||||
// lowercased filter key). Mirrors `activeQuery`'s mention-then-search order.
|
||||
const displayQuery = rawMentionQuery || pickerSearchQuery.trim();
|
||||
const filteredContextOptions = activeQuery
|
||||
? contextEntitiesByCategory[activeContextCategory].filter((entity) =>
|
||||
entity.value.toLowerCase().includes(activeQuery),
|
||||
@@ -1071,9 +1092,11 @@ export default function ChatInput({
|
||||
Failed to load {activeContextCategory.toLowerCase()}.
|
||||
</div>
|
||||
) : filteredContextOptions.length === 0 ? (
|
||||
<div className={styles.contextPopoverEmpty}>
|
||||
No matching entities
|
||||
</div>
|
||||
<ContextPickerEmptyState
|
||||
category={activeContextCategory}
|
||||
query={displayQuery}
|
||||
onPrefill={handleContextPrefill}
|
||||
/>
|
||||
) : (
|
||||
filteredContextOptions.map((option, index) => {
|
||||
const isSelected = selectedContexts.some(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Sparkles } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import styles from './ChatInput.module.scss';
|
||||
import {
|
||||
CONTEXT_CATEGORY_ICONS,
|
||||
ContextCategory,
|
||||
getContextPickerEmptyContent,
|
||||
} from './contextPicker';
|
||||
|
||||
// Per-category accent, mapped to semantic accent tokens (robin is the brand
|
||||
// primary). Exposed to the SCSS as the `--empty-accent` custom property so the
|
||||
// icon and CTA share one colour per category.
|
||||
const CATEGORY_ACCENT: Record<ContextCategory, string> = {
|
||||
Dashboards: 'var(--accent-primary)',
|
||||
Alerts: 'var(--accent-cherry)',
|
||||
Services: 'var(--accent-forest)',
|
||||
};
|
||||
|
||||
interface ContextPickerEmptyStateProps {
|
||||
category: ContextCategory;
|
||||
/** The active search query (mention or in-popover search), original case. */
|
||||
query: string;
|
||||
/** Drops the starter prompt into the composer (never auto-sends). */
|
||||
onPrefill: (prompt: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state for the @-mention context picker. Distinguishes a brand-new user
|
||||
* with nothing to pick (onboarding) from a search that matched nothing, and in
|
||||
* both cases offers a clickable CTA that seeds the composer.
|
||||
*/
|
||||
export default function ContextPickerEmptyState({
|
||||
category,
|
||||
query,
|
||||
onPrefill,
|
||||
}: ContextPickerEmptyStateProps): JSX.Element {
|
||||
const { title, ctaLabel, prefill } = getContextPickerEmptyContent(
|
||||
category,
|
||||
query,
|
||||
);
|
||||
const CategoryIcon = CONTEXT_CATEGORY_ICONS[category];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.contextPopoverEmpty}
|
||||
style={{ '--empty-accent': CATEGORY_ACCENT[category] } as CSSProperties}
|
||||
>
|
||||
<span className={styles.contextPopoverEmptyIcon} aria-hidden="true">
|
||||
<CategoryIcon size={16} />
|
||||
</span>
|
||||
<p className={styles.contextPopoverEmptyTitle}>{title}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className={styles.contextPopoverEmptyCta}
|
||||
onClick={(): void => onPrefill(prefill)}
|
||||
data-testid={`ai-context-empty-cta-${category}`}
|
||||
prefix={<Sparkles size={14} />}
|
||||
>
|
||||
<span className={styles.contextPopoverEmptyCtaLabel}>{ctaLabel}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
// The prefill flow only depends on the context-picker data hooks resolving to
|
||||
// empty lists (so the empty state renders) — mock them to skip real fetches.
|
||||
jest.mock('hooks/dashboard/useGetAllDashboard', () => ({
|
||||
useGetAllDashboard: (): unknown => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
|
||||
getListRulesQueryKey: (): string[] => ['rules'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
useQueryService: (): unknown => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Irrelevant to the prefill flow and otherwise require browser APIs / extra
|
||||
// context providers, so stub them out.
|
||||
jest.mock('../../../hooks/useSpeechRecognition', () => ({
|
||||
useSpeechRecognition: (): unknown => ({
|
||||
isListening: false,
|
||||
isSupported: false,
|
||||
permission: 'prompt',
|
||||
start: jest.fn(),
|
||||
discard: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({
|
||||
threadId: undefined,
|
||||
page: '/',
|
||||
mode: 'sidepane',
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
// eslint-disable-next-line import/first
|
||||
import ChatInput from '../ChatInput';
|
||||
|
||||
function renderChatInput(): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ChatInput onSend={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function getComposer(): HTMLTextAreaElement {
|
||||
return screen.getByPlaceholderText(/Ask anything/i) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
describe('ChatInput — empty-state CTA prefill flow', () => {
|
||||
it('full-replaces existing prose with the query-seeded prompt and closes the picker', async () => {
|
||||
renderChatInput();
|
||||
|
||||
// Pre-existing prose in the composer.
|
||||
await userEvent.type(getComposer(), 'show me something');
|
||||
|
||||
// Open the picker and search for an entity that does not exist.
|
||||
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText(/search dashboards/i),
|
||||
'chk',
|
||||
);
|
||||
|
||||
const cta = await screen.findByTestId('ai-context-empty-cta-Dashboards');
|
||||
expect(cta).toHaveTextContent('Create a dashboard for "chk"');
|
||||
|
||||
await userEvent.click(cta);
|
||||
|
||||
// Full-replace is intentional: the prefill is a complete sentence, so the
|
||||
// prior "show me something" prose is discarded rather than producing
|
||||
// broken grammar (see handleContextPrefill). The query is seeded in.
|
||||
expect(getComposer().value).toBe('Create a dashboard for chk');
|
||||
|
||||
// Picker closed → the empty-state CTA is gone.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId('ai-context-empty-cta-Dashboards'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('seeds only the prefix (with trailing space) in the onboarding case', async () => {
|
||||
renderChatInput();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
|
||||
|
||||
const cta = await screen.findByTestId('ai-context-empty-cta-Dashboards');
|
||||
expect(cta).toHaveTextContent('Ask me to create one');
|
||||
|
||||
await userEvent.click(cta);
|
||||
|
||||
expect(getComposer().value).toBe('Create a dashboard for ');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId('ai-context-empty-cta-Dashboards'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { ContextCategory } from '../contextPicker';
|
||||
import ContextPickerEmptyState from '../ContextPickerEmptyState';
|
||||
|
||||
function renderEmptyState(
|
||||
category: ContextCategory,
|
||||
query: string,
|
||||
onPrefill = jest.fn(),
|
||||
): { onPrefill: jest.Mock; container: HTMLElement } {
|
||||
const { container } = render(
|
||||
<ContextPickerEmptyState
|
||||
category={category}
|
||||
query={query}
|
||||
onPrefill={onPrefill}
|
||||
/>,
|
||||
);
|
||||
return { onPrefill, container };
|
||||
}
|
||||
|
||||
function ctaFor(category: ContextCategory): HTMLElement {
|
||||
return screen.getByTestId(`ai-context-empty-cta-${category}`);
|
||||
}
|
||||
|
||||
describe('ContextPickerEmptyState', () => {
|
||||
describe('onboarding (no query)', () => {
|
||||
it('renders dashboards copy and prefills the prefix only', async () => {
|
||||
const { onPrefill } = renderEmptyState('Dashboards', '');
|
||||
|
||||
expect(screen.getByText('No dashboards yet.')).toBeInTheDocument();
|
||||
const cta = ctaFor('Dashboards');
|
||||
expect(cta).toHaveTextContent('Ask me to create one');
|
||||
|
||||
await userEvent.click(cta);
|
||||
expect(onPrefill).toHaveBeenCalledTimes(1);
|
||||
expect(onPrefill).toHaveBeenCalledWith('Create a dashboard for ');
|
||||
});
|
||||
|
||||
it('renders alerts copy and prefills the prefix only', async () => {
|
||||
const { onPrefill } = renderEmptyState('Alerts', '');
|
||||
|
||||
expect(screen.getByText('No alerts yet.')).toBeInTheDocument();
|
||||
expect(ctaFor('Alerts')).toHaveTextContent('Ask me to create one');
|
||||
|
||||
await userEvent.click(ctaFor('Alerts'));
|
||||
expect(onPrefill).toHaveBeenCalledWith('Create an alert for ');
|
||||
});
|
||||
|
||||
it('renders instrumentation-flavoured services copy and prefill', async () => {
|
||||
const { onPrefill } = renderEmptyState('Services', '');
|
||||
|
||||
expect(
|
||||
screen.getByText('No services reporting data yet.'),
|
||||
).toBeInTheDocument();
|
||||
expect(ctaFor('Services')).toHaveTextContent(
|
||||
'Ask me to help set up instrumentation',
|
||||
);
|
||||
|
||||
await userEvent.click(ctaFor('Services'));
|
||||
expect(onPrefill).toHaveBeenCalledWith(
|
||||
'Help me set up instrumentation for ',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only query as onboarding', () => {
|
||||
renderEmptyState('Dashboards', ' ');
|
||||
expect(screen.getByText('No dashboards yet.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search miss (query, no match)', () => {
|
||||
it('seeds the query into dashboards copy and prefill', async () => {
|
||||
const { onPrefill } = renderEmptyState('Dashboards', 'checkout');
|
||||
|
||||
expect(
|
||||
screen.getByText('No dashboards match "checkout".'),
|
||||
).toBeInTheDocument();
|
||||
expect(ctaFor('Dashboards')).toHaveTextContent(
|
||||
'Create a dashboard for "checkout"',
|
||||
);
|
||||
|
||||
await userEvent.click(ctaFor('Dashboards'));
|
||||
expect(onPrefill).toHaveBeenCalledWith('Create a dashboard for checkout');
|
||||
});
|
||||
|
||||
it('seeds the query into alerts copy and prefill', async () => {
|
||||
const { onPrefill } = renderEmptyState('Alerts', 'checkout');
|
||||
|
||||
expect(screen.getByText('No alerts match "checkout".')).toBeInTheDocument();
|
||||
await userEvent.click(ctaFor('Alerts'));
|
||||
expect(onPrefill).toHaveBeenCalledWith('Create an alert for checkout');
|
||||
});
|
||||
|
||||
it('uses instrumentation wording for services search misses', async () => {
|
||||
const { onPrefill } = renderEmptyState('Services', 'checkout');
|
||||
|
||||
expect(
|
||||
screen.getByText('No services match "checkout".'),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(ctaFor('Services'));
|
||||
expect(onPrefill).toHaveBeenCalledWith(
|
||||
'Help me set up instrumentation for checkout',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the original casing of the query in copy and prefill', async () => {
|
||||
const { onPrefill } = renderEmptyState('Dashboards', 'Checkout API');
|
||||
|
||||
expect(
|
||||
screen.getByText('No dashboards match "Checkout API".'),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(ctaFor('Dashboards'));
|
||||
expect(onPrefill).toHaveBeenCalledWith(
|
||||
'Create a dashboard for Checkout API',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-category accent token', () => {
|
||||
it.each<[ContextCategory, string]>([
|
||||
['Dashboards', 'var(--accent-primary)'],
|
||||
['Alerts', 'var(--accent-cherry)'],
|
||||
['Services', 'var(--accent-forest)'],
|
||||
])('maps %s to the semantic accent %s', (category, accent) => {
|
||||
const { container } = renderEmptyState(category, '');
|
||||
const root = container.firstChild as HTMLElement;
|
||||
expect(root.style.getPropertyValue('--empty-accent')).toBe(accent);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not auto-send: nothing fires until the CTA is clicked', () => {
|
||||
const { onPrefill } = renderEmptyState('Dashboards', 'checkout');
|
||||
expect(onPrefill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
CONTEXT_CATEGORIES,
|
||||
getContextPickerEmptyContent,
|
||||
} from '../contextPicker';
|
||||
|
||||
describe('getContextPickerEmptyContent', () => {
|
||||
describe('onboarding (no query)', () => {
|
||||
it('returns per-category copy and prefix-only prefill for dashboards', () => {
|
||||
expect(getContextPickerEmptyContent('Dashboards', '')).toStrictEqual({
|
||||
title: 'No dashboards yet.',
|
||||
ctaLabel: 'Ask me to create one',
|
||||
prefill: 'Create a dashboard for ',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns per-category copy and prefix-only prefill for alerts', () => {
|
||||
expect(getContextPickerEmptyContent('Alerts', '')).toStrictEqual({
|
||||
title: 'No alerts yet.',
|
||||
ctaLabel: 'Ask me to create one',
|
||||
prefill: 'Create an alert for ',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns instrumentation-flavoured copy for services', () => {
|
||||
expect(getContextPickerEmptyContent('Services', '')).toStrictEqual({
|
||||
title: 'No services reporting data yet.',
|
||||
ctaLabel: 'Ask me to help set up instrumentation',
|
||||
prefill: 'Help me set up instrumentation for ',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a whitespace-only query as no query', () => {
|
||||
expect(getContextPickerEmptyContent('Dashboards', ' ')).toStrictEqual({
|
||||
title: 'No dashboards yet.',
|
||||
ctaLabel: 'Ask me to create one',
|
||||
prefill: 'Create a dashboard for ',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the prefill ending in a space so the caret sits after it', () => {
|
||||
CONTEXT_CATEGORIES.forEach((category) => {
|
||||
expect(getContextPickerEmptyContent(category, '').prefill).toMatch(/ $/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('search miss (query, no match)', () => {
|
||||
it('seeds the query into dashboards copy and prefill', () => {
|
||||
expect(getContextPickerEmptyContent('Dashboards', 'checkout')).toStrictEqual(
|
||||
{
|
||||
title: 'No dashboards match "checkout".',
|
||||
ctaLabel: 'Create a dashboard for "checkout"',
|
||||
prefill: 'Create a dashboard for checkout',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('seeds the query into alerts copy and prefill', () => {
|
||||
expect(getContextPickerEmptyContent('Alerts', 'checkout')).toStrictEqual({
|
||||
title: 'No alerts match "checkout".',
|
||||
ctaLabel: 'Create an alert for "checkout"',
|
||||
prefill: 'Create an alert for checkout',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses instrumentation wording for services search misses', () => {
|
||||
expect(getContextPickerEmptyContent('Services', 'checkout')).toStrictEqual({
|
||||
title: 'No services match "checkout".',
|
||||
ctaLabel: 'Set up instrumentation for "checkout"',
|
||||
prefill: 'Help me set up instrumentation for checkout',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the original casing of the query', () => {
|
||||
const { title, ctaLabel, prefill } = getContextPickerEmptyContent(
|
||||
'Dashboards',
|
||||
'Checkout API',
|
||||
);
|
||||
expect(title).toBe('No dashboards match "Checkout API".');
|
||||
expect(ctaLabel).toBe('Create a dashboard for "Checkout API"');
|
||||
expect(prefill).toBe('Create a dashboard for Checkout API');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from the query', () => {
|
||||
expect(
|
||||
getContextPickerEmptyContent('Dashboards', ' checkout ').prefill,
|
||||
).toBe('Create a dashboard for checkout');
|
||||
});
|
||||
});
|
||||
|
||||
it('never emits an em-dash (house style)', () => {
|
||||
CONTEXT_CATEGORIES.forEach((category) => {
|
||||
const empty = getContextPickerEmptyContent(category, '');
|
||||
const miss = getContextPickerEmptyContent(category, 'q');
|
||||
[empty, miss].forEach(({ title, ctaLabel, prefill }) => {
|
||||
expect(`${title}${ctaLabel}${prefill}`).not.toContain('—');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Bell, LayoutDashboard, ShieldCheck } from '@signozhq/icons';
|
||||
|
||||
/** Ordered category tabs shown in the @-mention context picker. */
|
||||
export const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
|
||||
|
||||
export type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* Icon per category, shared by the picker tablist and the empty state. `satisfies`
|
||||
* keeps the concrete component types so callers can render `<Icon size={n} />`.
|
||||
*/
|
||||
export const CONTEXT_CATEGORY_ICONS = {
|
||||
Dashboards: LayoutDashboard,
|
||||
Alerts: Bell,
|
||||
Services: ShieldCheck,
|
||||
} satisfies Record<ContextCategory, unknown>;
|
||||
|
||||
/**
|
||||
* Resolved copy + composer prefill for one render of the context picker's empty
|
||||
* state. The picker is tabbed, so a user only ever views one category at a
|
||||
* time — each category gets its own onboarding and search-miss copy rather than
|
||||
* a single combined "nothing to show" line.
|
||||
*/
|
||||
export interface ContextPickerEmptyContent {
|
||||
/** Primary line explaining why the list is empty. */
|
||||
title: string;
|
||||
/** Clickable call to action that prefills (never auto-sends) the composer. */
|
||||
ctaLabel: string;
|
||||
/**
|
||||
* Text dropped into the composer when the CTA is clicked. When there's no
|
||||
* search query this is just the prefix with a trailing space, leaving the
|
||||
* caret at the end for the user to type the entity name.
|
||||
*/
|
||||
prefill: string;
|
||||
}
|
||||
|
||||
interface CategoryCopy {
|
||||
/** Onboarding line, e.g. "No dashboards yet." */
|
||||
emptyTitle: string;
|
||||
/** Onboarding CTA label, e.g. "Ask me to create one". */
|
||||
emptyCtaLabel: string;
|
||||
/** Search-miss line, e.g. `No dashboards match "checkout".` */
|
||||
matchTitle: (query: string) => string;
|
||||
/** Search-miss CTA label, e.g. `Create a dashboard for "checkout"`. */
|
||||
matchCtaLabel: (query: string) => string;
|
||||
/**
|
||||
* Composer prefill prefix (with trailing space). The prefill is always
|
||||
* `${prefillPrefix}${query}`, so the search-miss case seeds the query and
|
||||
* the onboarding case leaves only the prefix.
|
||||
*/
|
||||
prefillPrefix: string;
|
||||
}
|
||||
|
||||
// Services get instrumentation-flavoured copy: the assistant can't "create" a
|
||||
// service, they come from telemetry, so the CTA points at setup instead.
|
||||
const CONTEXT_PICKER_COPY: Record<ContextCategory, CategoryCopy> = {
|
||||
Dashboards: {
|
||||
emptyTitle: 'No dashboards yet.',
|
||||
emptyCtaLabel: 'Ask me to create one',
|
||||
matchTitle: (query) => `No dashboards match "${query}".`,
|
||||
matchCtaLabel: (query) => `Create a dashboard for "${query}"`,
|
||||
prefillPrefix: 'Create a dashboard for ',
|
||||
},
|
||||
Alerts: {
|
||||
emptyTitle: 'No alerts yet.',
|
||||
emptyCtaLabel: 'Ask me to create one',
|
||||
matchTitle: (query) => `No alerts match "${query}".`,
|
||||
matchCtaLabel: (query) => `Create an alert for "${query}"`,
|
||||
prefillPrefix: 'Create an alert for ',
|
||||
},
|
||||
Services: {
|
||||
emptyTitle: 'No services reporting data yet.',
|
||||
emptyCtaLabel: 'Ask me to help set up instrumentation',
|
||||
matchTitle: (query) => `No services match "${query}".`,
|
||||
matchCtaLabel: (query) => `Set up instrumentation for "${query}"`,
|
||||
prefillPrefix: 'Help me set up instrumentation for ',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the empty-state copy for a category. The two states are driven solely
|
||||
* by whether a search query is active: a non-empty query yields the search-miss
|
||||
* variant, an empty query the onboarding variant.
|
||||
*/
|
||||
export function getContextPickerEmptyContent(
|
||||
category: ContextCategory,
|
||||
query: string,
|
||||
): ContextPickerEmptyContent {
|
||||
const copy = CONTEXT_PICKER_COPY[category];
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) {
|
||||
return {
|
||||
title: copy.matchTitle(trimmed),
|
||||
ctaLabel: copy.matchCtaLabel(trimmed),
|
||||
prefill: `${copy.prefillPrefix}${trimmed}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: copy.emptyTitle,
|
||||
ctaLabel: copy.emptyCtaLabel,
|
||||
prefill: copy.prefillPrefix,
|
||||
};
|
||||
}
|
||||
@@ -75,6 +75,38 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// Error bubble: a subtle error-tinted callout replacing the default
|
||||
// assistant background, rendered when a turn fails.
|
||||
.bubble.error {
|
||||
.assistant & {
|
||||
background: var(--callout-error-background);
|
||||
border: 1px solid var(--callout-error-border);
|
||||
}
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--callout-error-title);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.retryButton {
|
||||
margin-top: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
// User-bubble row: pencil button sits to the LEFT of the bubble within
|
||||
// the right-aligned message line, so it visually "ends" at the bubble's
|
||||
// right edge while keeping the bubble in its original position.
|
||||
|
||||
@@ -2,6 +2,10 @@ import React, { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { RotateCw, TriangleAlert } from '@signozhq/icons';
|
||||
|
||||
import { RetryActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
// Side-effect: registers all built-in block types into the BlockRegistry
|
||||
import '../blocks';
|
||||
@@ -104,18 +108,23 @@ function renderGroup(group: RenderGroup): JSX.Element {
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
onRegenerate?: () => void;
|
||||
onRetry?: () => void;
|
||||
isLastAssistant?: boolean;
|
||||
}
|
||||
|
||||
export default function MessageBubble({
|
||||
message,
|
||||
onRegenerate,
|
||||
onRetry,
|
||||
isLastAssistant = false,
|
||||
}: MessageBubbleProps): JSX.Element {
|
||||
const variant = useVariant();
|
||||
const isCompact = variant === 'panel';
|
||||
const isUser = message.role === 'user';
|
||||
const isError = !isUser && Boolean(message.isError);
|
||||
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
|
||||
const showRetry =
|
||||
isError && message.retryAction === RetryActionDTO.manual && Boolean(onRetry);
|
||||
|
||||
// Recompute groups only when the blocks array identity changes — store
|
||||
// updates that don't touch this message's blocks should not re-render the
|
||||
@@ -138,7 +147,7 @@ export default function MessageBubble({
|
||||
<div className={messageClass} data-testid={`ai-message-${message.id}`}>
|
||||
<div className={bodyClass}>
|
||||
<div className={styles.bubbleRow}>
|
||||
<div className={styles.bubble}>
|
||||
<div className={cx(styles.bubble, { [styles.error]: isError })}>
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
{message.attachments.map((att) => {
|
||||
@@ -161,6 +170,11 @@ export default function MessageBubble({
|
||||
|
||||
{isUser ? (
|
||||
<p className={styles.text}>{message.content}</p>
|
||||
) : isError ? (
|
||||
<div className={styles.errorContent}>
|
||||
<TriangleAlert size={14} className={styles.errorIcon} />
|
||||
<span className={styles.errorText}>{message.content}</span>
|
||||
</div>
|
||||
) : hasBlocks ? (
|
||||
<MessageContext.Provider value={{ messageId: message.id }}>
|
||||
{groups.map((g) => renderGroup(g))}
|
||||
@@ -183,7 +197,21 @@ export default function MessageBubble({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isUser && !message.isRateLimitError && (
|
||||
{showRetry && (
|
||||
<Button
|
||||
className={styles.retryButton}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onRetry}
|
||||
testId={`ai-message-retry-${message.id}`}
|
||||
>
|
||||
<RotateCw size={12} />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isUser && !isError && !message.isRateLimitError && (
|
||||
<MessageFeedback
|
||||
message={message}
|
||||
onRegenerate={onRegenerate}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
RetryActionDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import { Message } from '../../../types';
|
||||
|
||||
// react-markdown + remark-gfm are ESM-only and pull a large untransformed
|
||||
// dependency chain into jest. The error-rendering path under test renders
|
||||
// plain text (no markdown), so stub them to keep the import graph loadable.
|
||||
jest.mock('react-markdown', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children?: React.ReactNode }): React.ReactNode =>
|
||||
children,
|
||||
}));
|
||||
jest.mock('remark-gfm', () => ({
|
||||
__esModule: true,
|
||||
default: (): void => undefined,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import MessageBubble from '../MessageBubble';
|
||||
|
||||
function errorMessage(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: 'err-1',
|
||||
role: 'assistant',
|
||||
content: 'This conversation is still finishing a previous response.',
|
||||
isError: true,
|
||||
errorCode: ErrorCodeDTO.thread_busy,
|
||||
retryAction: RetryActionDTO.manual,
|
||||
createdAt: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const retryButton = (): HTMLElement | null =>
|
||||
screen.queryByRole('button', { name: /retry/i });
|
||||
|
||||
describe('MessageBubble — error rendering', () => {
|
||||
it('shows a Retry button for a manual error and invokes onRetry on click', async () => {
|
||||
const onRetry = jest.fn();
|
||||
render(<MessageBubble message={errorMessage()} onRetry={onRetry} />);
|
||||
|
||||
// Error copy is rendered, and the feedback bar is suppressed on errors.
|
||||
expect(
|
||||
screen.getByText(/still finishing a previous response/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /copy message/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const button = retryButton();
|
||||
expect(button).toBeInTheDocument();
|
||||
await userEvent.click(button as HTMLElement);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides the Retry button when retryAction is none', () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={errorMessage({ retryAction: RetryActionDTO.none })}
|
||||
onRetry={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(retryButton()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Retry button when retryAction is auto', () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={errorMessage({ retryAction: RetryActionDTO.auto })}
|
||||
onRetry={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(retryButton()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Retry button when no onRetry handler is provided', () => {
|
||||
render(<MessageBubble message={errorMessage()} />);
|
||||
expect(retryButton()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,9 @@ export default function VirtualizedMessages({
|
||||
const regenerateAssistantMessage = useAIAssistantStore(
|
||||
(s) => s.regenerateAssistantMessage,
|
||||
);
|
||||
const retryAssistantMessage = useAIAssistantStore(
|
||||
(s) => s.retryAssistantMessage,
|
||||
);
|
||||
const { threadId } = useAIAssistantAnalyticsContext(conversationId);
|
||||
const streamingStatus = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.streamingStatus ?? '',
|
||||
@@ -85,6 +88,14 @@ export default function VirtualizedMessages({
|
||||
[conversationId, isStreaming, regenerateAssistantMessage, threadId],
|
||||
);
|
||||
|
||||
const handleRetry = useCallback((): void => {
|
||||
if (isStreaming) {
|
||||
return;
|
||||
}
|
||||
void logEvent(AIAssistantEvents.RetryClicked, { threadId });
|
||||
void retryAssistantMessage(conversationId);
|
||||
}, [conversationId, isStreaming, retryAssistantMessage, threadId]);
|
||||
|
||||
// Scroll all the way to the actual bottom — including the 64px of bottom
|
||||
// padding on the scroller — so the last bubble has visible breathing room
|
||||
// above the disclaimer / input bar. Virtuoso's `scrollToIndex(LAST,
|
||||
@@ -206,6 +217,11 @@ export default function VirtualizedMessages({
|
||||
? (): void => handleRegenerate(msg.id)
|
||||
: undefined
|
||||
}
|
||||
onRetry={
|
||||
msg.isError && isLastAssistant && !showStreamingSlot
|
||||
? handleRetry
|
||||
: undefined
|
||||
}
|
||||
isLastAssistant={isLastAssistant}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -90,6 +90,7 @@ export enum AIAssistantEvents {
|
||||
SuggestedPromptClicked = 'AI Assistant: Suggested prompt clicked',
|
||||
CancelClicked = 'AI Assistant: Cancel clicked',
|
||||
RegenerateClicked = 'AI Assistant: Regenerate clicked',
|
||||
RetryClicked = 'AI Assistant: Retry clicked',
|
||||
MessageCopied = 'AI Assistant: Message copied',
|
||||
FeedbackSubmitted = 'AI Assistant: Feedback submitted',
|
||||
ResourceOpened = 'AI Assistant: Resource opened',
|
||||
|
||||
@@ -124,7 +124,9 @@ export function getAutoContexts(
|
||||
}
|
||||
}
|
||||
|
||||
// Alert edit — `/alerts/edit?ruleId=…`.
|
||||
// Alert edit — `/alerts/edit?ruleId=…`. The form syncs its query-builder
|
||||
// state to the URL (`useShareBuilderUrl`), so shared metadata carries the
|
||||
// alert's query + time range, mirroring the dashboard panel editor.
|
||||
if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) {
|
||||
const ruleId = params.get(QueryParams.ruleId);
|
||||
if (ruleId) {
|
||||
@@ -133,19 +135,21 @@ export function getAutoContexts(
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: { page: 'alert_edit', ruleId },
|
||||
metadata: { page: 'alert_edit', ruleId, ...sharedMetadata },
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Alert new — `/alerts/new`. No rule id yet (draft), but the query-builder
|
||||
// state is on the URL, so shared metadata carries the in-progress query.
|
||||
if (matchPath(pathname, { path: ROUTES.ALERTS_NEW, exact: true })) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: { page: 'alert_new' },
|
||||
metadata: { page: 'alert_new', ...sharedMetadata },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
RetryActionDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import type { SSEEvent } from 'api/ai-assistant/chat';
|
||||
|
||||
import { useAIAssistantStore } from '../useAIAssistantStore';
|
||||
import type { Message } from '../../types';
|
||||
|
||||
// The store talks to the chat API only through these named exports. Mock the
|
||||
// whole module so we can drive the SSE stream + REST calls deterministically.
|
||||
jest.mock('api/ai-assistant/chat', () => ({
|
||||
__esModule: true,
|
||||
createThread: jest.fn(),
|
||||
sendMessage: jest.fn(),
|
||||
streamEvents: jest.fn(),
|
||||
approveExecution: jest.fn(),
|
||||
clarifyExecution: jest.fn(),
|
||||
regenerateMessage: jest.fn(),
|
||||
rejectExecution: jest.fn(),
|
||||
cancelExecution: jest.fn(),
|
||||
listThreads: jest.fn(),
|
||||
getThreadDetail: jest.fn(),
|
||||
updateThread: jest.fn(),
|
||||
submitFeedback: jest.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
||||
const chat = jest.requireMock('api/ai-assistant/chat') as Record<
|
||||
string,
|
||||
jest.Mock
|
||||
>;
|
||||
|
||||
// Builds a single-use async stream from a fixed list of SSE events.
|
||||
async function* eventStream(events: SSEEvent[]): AsyncGenerator<SSEEvent> {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
function errorEvent(
|
||||
executionId: string,
|
||||
code: ErrorCodeDTO,
|
||||
retryAction: RetryActionDTO,
|
||||
): SSEEvent {
|
||||
return {
|
||||
type: 'error',
|
||||
executionId,
|
||||
error: { code, message: 'backend message' },
|
||||
retryAction,
|
||||
};
|
||||
}
|
||||
|
||||
function lastMessage(conversationId: string): Message {
|
||||
const conv = useAIAssistantStore.getState().conversations[conversationId];
|
||||
return conv.messages[conv.messages.length - 1];
|
||||
}
|
||||
|
||||
describe('useAIAssistantStore — streaming error handling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useAIAssistantStore.setState((s) => {
|
||||
s.conversations = {};
|
||||
s.streams = {};
|
||||
s.activeConversationId = null;
|
||||
});
|
||||
});
|
||||
|
||||
it('commits a manually-retryable error bubble with friendly copy and metadata', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-1');
|
||||
chat.sendMessage.mockResolvedValue('exec-1');
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec-1', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hello');
|
||||
|
||||
const conv = useAIAssistantStore.getState().conversations['thread-1'];
|
||||
expect(conv.messages).toHaveLength(2);
|
||||
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hello' });
|
||||
expect(conv.messages[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
isError: true,
|
||||
errorCode: ErrorCodeDTO.thread_busy,
|
||||
retryAction: RetryActionDTO.manual,
|
||||
});
|
||||
// Code-specific FE copy, not the raw backend message.
|
||||
expect(conv.messages[1].content).toContain(
|
||||
'still finishing a previous response',
|
||||
);
|
||||
});
|
||||
|
||||
it('replays the send on retry without re-pushing the user message', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-1');
|
||||
chat.sendMessage.mockResolvedValue('exec-1');
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec-1', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hello');
|
||||
|
||||
// The retry succeeds this time.
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
{
|
||||
type: 'message',
|
||||
executionId: 'exec-1',
|
||||
messageId: 'm1',
|
||||
delta: 'Hi there',
|
||||
done: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await useAIAssistantStore.getState().retryAssistantMessage('thread-1');
|
||||
|
||||
const conv = useAIAssistantStore.getState().conversations['thread-1'];
|
||||
// Error bubble replaced by the assistant reply; the user message stays.
|
||||
expect(conv.messages).toHaveLength(2);
|
||||
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hello' });
|
||||
expect(conv.messages[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'Hi there',
|
||||
});
|
||||
expect(conv.messages[1].isError).toBeUndefined();
|
||||
// Thread already existed on retry; the user message was never re-sent as new.
|
||||
expect(chat.createThread).toHaveBeenCalledTimes(1);
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('silently retries auto-flagged errors, then downgrades to manual once spent', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-2');
|
||||
chat.sendMessage.mockResolvedValue('exec');
|
||||
// Always auto-retryable: 1 initial attempt + MAX_AUTO_RETRIES (2) = 3 sends.
|
||||
chat.streamEvents.mockImplementation(() =>
|
||||
eventStream([
|
||||
errorEvent('exec', ErrorCodeDTO.internal_error, RetryActionDTO.auto),
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hi');
|
||||
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(3);
|
||||
expect(lastMessage('thread-2')).toMatchObject({
|
||||
isError: true,
|
||||
errorCode: ErrorCodeDTO.internal_error,
|
||||
// Auto budget exhausted → presented as manual so a Retry button shows.
|
||||
retryAction: RetryActionDTO.manual,
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
it('marks rate-limit errors and offers no retry', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-3');
|
||||
chat.sendMessage.mockResolvedValue('exec');
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec', ErrorCodeDTO.hourly_message_limit, RetryActionDTO.none),
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hi');
|
||||
|
||||
expect(lastMessage('thread-3')).toMatchObject({
|
||||
isError: true,
|
||||
isRateLimitError: true,
|
||||
retryAction: RetryActionDTO.none,
|
||||
});
|
||||
|
||||
// No retry thunk registered for a non-retryable error — retry is a no-op.
|
||||
const before =
|
||||
useAIAssistantStore.getState().conversations['thread-3'].messages.length;
|
||||
await useAIAssistantStore.getState().retryAssistantMessage('thread-3');
|
||||
expect(
|
||||
useAIAssistantStore.getState().conversations['thread-3'].messages,
|
||||
).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('recovers silently when an auto-flagged error succeeds on retry', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-4');
|
||||
chat.sendMessage.mockResolvedValue('exec');
|
||||
chat.streamEvents
|
||||
.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec', ErrorCodeDTO.internal_error, RetryActionDTO.auto),
|
||||
]),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
eventStream([
|
||||
{
|
||||
type: 'message',
|
||||
executionId: 'exec',
|
||||
messageId: 'm1',
|
||||
delta: 'Recovered',
|
||||
done: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hi');
|
||||
|
||||
// 1 initial attempt + 1 silent auto retry, then success — no error bubble.
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(2);
|
||||
const conv = useAIAssistantStore.getState().conversations['thread-4'];
|
||||
expect(conv.messages).toHaveLength(2);
|
||||
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hi' });
|
||||
expect(conv.messages[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'Recovered',
|
||||
});
|
||||
expect(conv.messages.some((m) => m.isError)).toBe(false);
|
||||
}, 10000);
|
||||
|
||||
it('replays the originating action on retry for a non-send error (approve)', async () => {
|
||||
chat.approveExecution.mockResolvedValue('exec-a');
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec-a', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
|
||||
]),
|
||||
);
|
||||
|
||||
const convId = useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().approveAction(convId, 'approval-1');
|
||||
|
||||
expect(lastMessage(convId)).toMatchObject({
|
||||
isError: true,
|
||||
retryAction: RetryActionDTO.manual,
|
||||
});
|
||||
expect(chat.approveExecution).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Retry replays the approval (not a send) and succeeds this time.
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
{
|
||||
type: 'message',
|
||||
executionId: 'exec-a',
|
||||
messageId: 'm1',
|
||||
delta: 'Approved',
|
||||
done: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await useAIAssistantStore.getState().retryAssistantMessage(convId);
|
||||
|
||||
const conv = useAIAssistantStore.getState().conversations[convId];
|
||||
expect(conv.messages).toHaveLength(1);
|
||||
expect(conv.messages[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'Approved',
|
||||
});
|
||||
expect(conv.messages[0].isError).toBeUndefined();
|
||||
expect(chat.approveExecution).toHaveBeenCalledTimes(2);
|
||||
expect(chat.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
MessageActionDTO,
|
||||
MessageSummaryDTOBlocksAnyOfItem,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { RetryActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import {
|
||||
approveExecution,
|
||||
@@ -35,7 +36,10 @@ import {
|
||||
MessageBlock,
|
||||
MessageRole,
|
||||
} from '../types';
|
||||
import { resolveAssistantErrorMessage } from '../utils/resolveAssistantErrorMessage';
|
||||
import {
|
||||
resolveAssistantError,
|
||||
type AssistantErrorResolution,
|
||||
} from '../utils/resolveAssistantError';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types used by module-level helpers
|
||||
@@ -56,6 +60,15 @@ interface SSEStreamCtx {
|
||||
|
||||
const streamControllers = new Map<string, AbortController>();
|
||||
|
||||
/**
|
||||
* Per-conversation retry thunks for the most recent failed turn. Populated by
|
||||
* `finalizeStreamingError` when the error is manually retryable; consumed by
|
||||
* the `retryAssistantMessage` action when the user clicks Retry. Transient
|
||||
* (not persisted) — it shares the in-memory lifetime of the error bubble it
|
||||
* backs, so a page reload drops both together.
|
||||
*/
|
||||
const retryRegistry = new Map<string, () => Promise<void>>();
|
||||
|
||||
function abortStream(conversationId: string): void {
|
||||
const ctrl = streamControllers.get(conversationId);
|
||||
if (ctrl) {
|
||||
@@ -197,7 +210,7 @@ function resetStreamingState(
|
||||
* Marker thrown by `runStreamingLoop` when an SSE event reports
|
||||
* `invalid_token`. Callers that own an originating action (sendMessage /
|
||||
* approve / clarify / regenerate) catch this and re-issue that action via
|
||||
* `streamWithAuthRetry`; the retry's first REST call will 401, at which point
|
||||
* `streamWithRetry`; the retry's first REST call will 401, at which point
|
||||
* the shared axios `interceptorRejected` rotates the access token and replays.
|
||||
*/
|
||||
class AuthExpiredError extends Error {
|
||||
@@ -207,27 +220,50 @@ class AuthExpiredError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Capped silent re-attempts for backend-flagged transient (`auto`) errors. */
|
||||
const MAX_AUTO_RETRIES = 2;
|
||||
/** Backoff before each auto re-attempt, indexed by prior auto-retry count. */
|
||||
const AUTO_RETRY_BACKOFF_MS = [500, 1500];
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/** True when the SSE error carries the backend's `retryAction: 'auto'` flag. */
|
||||
function isAutoRetryableError(err: unknown): boolean {
|
||||
return (
|
||||
(err as { retryAction?: unknown } | undefined)?.retryAction ===
|
||||
RetryActionDTO.auto
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the originating action (e.g. sendMessage POST) and streams the
|
||||
* resulting execution. On `AuthExpiredError`, re-issues `start` once — the
|
||||
* retry's REST call hits 401, the shared axios interceptor rotates the
|
||||
* access token and replays, and the new SSE picks up the rotated token from
|
||||
* localStorage. Backend signals `retryAction: 'manual'` for `invalid_token`,
|
||||
* so the dead execution can't be resumed — only a fresh one helps.
|
||||
* resulting execution, with two independent retry budgets:
|
||||
*
|
||||
* • Auth — on `AuthExpiredError` (SSE `invalid_token`), re-issues `start`
|
||||
* once. The retry's REST call 401s, the shared axios interceptor rotates
|
||||
* the access token + replays, and the new SSE picks up the rotated token.
|
||||
* Backend flags `invalid_token` as `manual`, so only a fresh execution helps.
|
||||
* • Auto — on an SSE error the backend flagged `retryAction: 'auto'`
|
||||
* (transient), silently re-issues `start` up to `MAX_AUTO_RETRIES` times
|
||||
* with backoff. Once exhausted the error propagates so the caller can
|
||||
* surface a manual Retry affordance.
|
||||
*
|
||||
* Both reset the stream state before re-attempting so a dead execution's
|
||||
* partial output isn't concatenated onto the retry.
|
||||
*/
|
||||
async function streamWithAuthRetry(
|
||||
async function streamWithRetry(
|
||||
conversationId: string,
|
||||
start: () => Promise<string>,
|
||||
set: StoreSetter,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt <= 1; attempt += 1) {
|
||||
if (attempt > 0) {
|
||||
// Drop any partial content/events from the previous attempt so the
|
||||
// retried execution's stream isn't concatenated with the dead one.
|
||||
set((s) => {
|
||||
resetStreamingState(s, conversationId);
|
||||
});
|
||||
}
|
||||
let authRetried = false;
|
||||
let autoRetries = 0;
|
||||
|
||||
for (;;) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const executionId = await start();
|
||||
const ctrl = newStreamController(conversationId);
|
||||
@@ -242,10 +278,28 @@ async function streamWithAuthRetry(
|
||||
return;
|
||||
} catch (err) {
|
||||
streamControllers.delete(conversationId);
|
||||
if (err instanceof AuthExpiredError && attempt < 1) {
|
||||
continue;
|
||||
|
||||
if (err instanceof AuthExpiredError && !authRetried) {
|
||||
authRetried = true;
|
||||
} else if (isAutoRetryableError(err) && autoRetries < MAX_AUTO_RETRIES) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await delay(AUTO_RETRY_BACKOFF_MS[autoRetries] ?? 1500);
|
||||
autoRetries += 1;
|
||||
} else {
|
||||
if (isAutoRetryableError(err)) {
|
||||
// Auto-retry budget spent — present the failure as manually
|
||||
// retryable so the caller surfaces a Retry button rather than
|
||||
// silently giving up.
|
||||
(err as { retryAction?: RetryActionDTO }).retryAction =
|
||||
RetryActionDTO.manual;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
|
||||
// Drop partial content/events from the failed attempt before retrying.
|
||||
set((s) => {
|
||||
resetStreamingState(s, conversationId);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,7 +312,7 @@ async function streamWithAuthRetry(
|
||||
*
|
||||
* On an `invalid_token` error event (e.g. MCP auth expired mid-execution),
|
||||
* throws `AuthExpiredError` so the caller can re-issue the originating
|
||||
* action via `streamWithAuthRetry`. We don't refresh here ourselves — the
|
||||
* action via `streamWithRetry`. We don't refresh here ourselves — the
|
||||
* retry's REST call will 401 and the shared axios `interceptorRejected`
|
||||
* handles rotation + replay. Throws on any other `error` event — the
|
||||
* caller's catch block handles UI feedback.
|
||||
@@ -484,20 +538,37 @@ function hasPendingInput(conversationId: string, get: StoreGetter): boolean {
|
||||
return Boolean(stream?.pendingApproval || stream?.pendingClarification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a failed turn as an error message and removes the stream entry.
|
||||
* When the failure is manually retryable and a `retry` thunk is supplied, the
|
||||
* thunk is stashed in `retryRegistry` so the bubble's Retry button can replay
|
||||
* the originating action.
|
||||
*/
|
||||
function finalizeStreamingError(
|
||||
conversationId: string,
|
||||
errorContent: string,
|
||||
resolution: AssistantErrorResolution,
|
||||
set: StoreSetter,
|
||||
isRateLimit = false,
|
||||
retry?: () => Promise<void>,
|
||||
): void {
|
||||
const { message, code, retryAction, isRateLimit } = resolution;
|
||||
|
||||
if (retryAction === RetryActionDTO.manual && retry) {
|
||||
retryRegistry.set(conversationId, retry);
|
||||
} else {
|
||||
retryRegistry.delete(conversationId);
|
||||
}
|
||||
|
||||
set((s) => {
|
||||
const conv = s.conversations[conversationId];
|
||||
if (conv) {
|
||||
conv.messages.push({
|
||||
id: uuidv4(),
|
||||
role: 'assistant',
|
||||
content: errorContent,
|
||||
content: message,
|
||||
createdAt: Date.now(),
|
||||
isError: true,
|
||||
retryAction,
|
||||
...(code ? { errorCode: code } : {}),
|
||||
...(isRateLimit ? { isRateLimitError: true } : {}),
|
||||
});
|
||||
conv.updatedAt = Date.now();
|
||||
@@ -506,6 +577,40 @@ function finalizeStreamingError(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared streaming wrapper for actions that have no pre-stream setup beyond
|
||||
* resetting state (approve / clarify / regenerate). Streams the execution,
|
||||
* finalizes the message on success, and on failure resolves the error +
|
||||
* registers `retry` (the caller's own re-invocation) so the bubble can replay
|
||||
* it. `sendMessage` does not use this — it owns thread-creation/re-keying and
|
||||
* runs its own equivalent loop.
|
||||
*/
|
||||
async function streamAndFinalize(
|
||||
conversationId: string,
|
||||
start: () => Promise<string>,
|
||||
fallback: string,
|
||||
logLabel: string,
|
||||
set: StoreSetter,
|
||||
get: StoreGetter,
|
||||
retry: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await streamWithRetry(conversationId, start, set);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
// Abort errors are expected when the user cancels — not a failure.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(logLabel, err);
|
||||
const resolution = resolveAssistantError(err, fallback);
|
||||
finalizeStreamingError(conversationId, resolution, set, retry);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store interface
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -564,6 +669,8 @@ export interface AIAssistantStore {
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
) => Promise<void>;
|
||||
/** Replays the originating action for a manually-retryable error bubble. */
|
||||
retryAssistantMessage: (conversationId: string) => Promise<void>;
|
||||
submitMessageFeedback: (
|
||||
messageId: string,
|
||||
rating: FeedbackRating,
|
||||
@@ -877,7 +984,7 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
// there's no "originating action" to redo — reopening the
|
||||
// same dead executionId would just re-emit the failure.
|
||||
// Let the error bubble; the user can send a new message,
|
||||
// which will go through `streamWithAuthRetry`.
|
||||
// which will go through `streamWithRetry`.
|
||||
if (
|
||||
detail.activeExecutionId &&
|
||||
!streamControllers.has(threadId) &&
|
||||
@@ -1060,7 +1167,7 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
attachments?: MessageAttachment[],
|
||||
contexts?: MessageContext[],
|
||||
): Promise<void> => {
|
||||
let convId = get().activeConversationId;
|
||||
const convId = get().activeConversationId;
|
||||
if (!convId || !get().conversations[convId]) {
|
||||
return;
|
||||
}
|
||||
@@ -1093,63 +1200,75 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
const conv = state.conversations[convId!];
|
||||
const conv = state.conversations[convId];
|
||||
conv.messages.push(userMessage);
|
||||
conv.updatedAt = Date.now();
|
||||
if (!conv.title && text.trim()) {
|
||||
conv.title = deriveTitle(text);
|
||||
}
|
||||
resetStreamingState(state, convId!);
|
||||
resetStreamingState(state, convId);
|
||||
});
|
||||
|
||||
try {
|
||||
let { threadId } = get().conversations[convId];
|
||||
if (!threadId) {
|
||||
threadId = await createThread();
|
||||
// Re-key the conversation from client UUID to backend threadId
|
||||
// so fetchThreads won't create a duplicate entry later.
|
||||
const oldId = convId;
|
||||
convId = threadId;
|
||||
set((s) => {
|
||||
const conv = s.conversations[oldId];
|
||||
if (conv) {
|
||||
conv.id = convId!;
|
||||
conv.threadId = convId!;
|
||||
s.conversations[convId!] = conv;
|
||||
delete s.conversations[oldId];
|
||||
if (s.activeConversationId === oldId) {
|
||||
s.activeConversationId = convId!;
|
||||
// The full send — ensure a backend thread exists (re-keying the
|
||||
// optimistic client UUID on first send), POST the message, and
|
||||
// stream the reply. Defined as a closure so the error bubble's
|
||||
// Retry button can replay it without re-pushing the user message.
|
||||
const runSend = async (cid: string): Promise<void> => {
|
||||
let targetConvId = cid;
|
||||
try {
|
||||
let { threadId } = get().conversations[targetConvId];
|
||||
if (!threadId) {
|
||||
threadId = await createThread();
|
||||
// Re-key the conversation from client UUID to backend threadId
|
||||
// so fetchThreads won't create a duplicate entry later.
|
||||
const oldId = targetConvId;
|
||||
const newId = threadId;
|
||||
set((s) => {
|
||||
const conv = s.conversations[oldId];
|
||||
if (conv) {
|
||||
conv.id = newId;
|
||||
conv.threadId = newId;
|
||||
s.conversations[newId] = conv;
|
||||
delete s.conversations[oldId];
|
||||
if (s.activeConversationId === oldId) {
|
||||
s.activeConversationId = newId;
|
||||
}
|
||||
const stream = s.streams[oldId];
|
||||
if (stream) {
|
||||
s.streams[newId] = stream;
|
||||
delete s.streams[oldId];
|
||||
}
|
||||
}
|
||||
const stream = s.streams[oldId];
|
||||
if (stream) {
|
||||
s.streams[convId!] = stream;
|
||||
delete s.streams[oldId];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const tid = threadId;
|
||||
await streamWithAuthRetry(
|
||||
convId,
|
||||
() => sendMessageToThread(tid, text, contexts),
|
||||
set,
|
||||
);
|
||||
});
|
||||
targetConvId = newId;
|
||||
}
|
||||
const tid = threadId;
|
||||
await streamWithRetry(
|
||||
targetConvId,
|
||||
() => sendMessageToThread(tid, text, contexts),
|
||||
set,
|
||||
);
|
||||
|
||||
if (!hasPendingInput(convId, get)) {
|
||||
finalizeStreamingMessage(convId, set, get);
|
||||
if (!hasPendingInput(targetConvId, get)) {
|
||||
finalizeStreamingMessage(targetConvId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
// Abort errors are expected when the user cancels — not a failure.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] sendMessage failed:', err);
|
||||
const resolution = resolveAssistantError(
|
||||
err,
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
);
|
||||
finalizeStreamingError(targetConvId, resolution, set, () =>
|
||||
runSend(targetConvId),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Abort errors are expected when the user cancels — not a failure
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] sendMessage failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
);
|
||||
finalizeStreamingError(convId, message, set, isRateLimit);
|
||||
}
|
||||
};
|
||||
|
||||
await runSend(convId);
|
||||
},
|
||||
|
||||
approveAction: async (
|
||||
@@ -1167,26 +1286,17 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await streamWithAuthRetry(
|
||||
const run = (): Promise<void> =>
|
||||
streamAndFinalize(
|
||||
conversationId,
|
||||
() => approveExecution(approvalId),
|
||||
set,
|
||||
);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] approveAction failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
'[AIAssistant] approveAction failed:',
|
||||
set,
|
||||
get,
|
||||
run,
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
await run();
|
||||
},
|
||||
|
||||
rejectAction: async (
|
||||
@@ -1246,26 +1356,17 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
resetStreamingState(s, conversationId);
|
||||
});
|
||||
|
||||
try {
|
||||
await streamWithAuthRetry(
|
||||
const run = (): Promise<void> =>
|
||||
streamAndFinalize(
|
||||
conversationId,
|
||||
() => regenerateMessage(messageId),
|
||||
set,
|
||||
);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] regenerateAssistantMessage failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
'[AIAssistant] regenerateAssistantMessage failed:',
|
||||
set,
|
||||
get,
|
||||
run,
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
await run();
|
||||
},
|
||||
|
||||
submitMessageFeedback: async (
|
||||
@@ -1312,26 +1413,42 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await streamWithAuthRetry(
|
||||
const run = (): Promise<void> =>
|
||||
streamAndFinalize(
|
||||
conversationId,
|
||||
() => clarifyExecution(clarificationId, answers),
|
||||
set,
|
||||
);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] submitClarification failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
'[AIAssistant] submitClarification failed:',
|
||||
set,
|
||||
get,
|
||||
run,
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
await run();
|
||||
},
|
||||
|
||||
retryAssistantMessage: async (conversationId: string): Promise<void> => {
|
||||
const retry = retryRegistry.get(conversationId);
|
||||
if (!retry) {
|
||||
return;
|
||||
}
|
||||
retryRegistry.delete(conversationId);
|
||||
|
||||
// Drop the trailing error bubble we're retrying from and reset the
|
||||
// stream so the in-progress retry renders immediately. The retry
|
||||
// thunk replays the originating action without re-pushing the
|
||||
// user's message.
|
||||
set((s) => {
|
||||
const conv = s.conversations[conversationId];
|
||||
if (conv) {
|
||||
const last = conv.messages[conv.messages.length - 1];
|
||||
if (last?.isError) {
|
||||
conv.messages.pop();
|
||||
}
|
||||
}
|
||||
resetStreamingState(s, conversationId);
|
||||
});
|
||||
|
||||
await retry();
|
||||
},
|
||||
})),
|
||||
{
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
import type {
|
||||
ApprovalEventDTO,
|
||||
ClarificationEventDTO,
|
||||
ErrorCodeDTO,
|
||||
FeedbackRatingDTO,
|
||||
MessageActionDTO,
|
||||
MessageActionKindDTO,
|
||||
RetryActionDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
/** Client-only file attachment — no API equivalent (uploads happen via data URLs). */
|
||||
@@ -91,6 +93,18 @@ export interface Message {
|
||||
* bar (copy/vote/regenerate) is hidden — retrying would just 429 again.
|
||||
*/
|
||||
isRateLimitError?: boolean;
|
||||
/**
|
||||
* Marks an assistant message that represents a failed turn. Drives the
|
||||
* error styling and replaces the feedback bar with a retry affordance.
|
||||
*/
|
||||
isError?: boolean;
|
||||
/** Known backend error code for the failure, when recognised. */
|
||||
errorCode?: ErrorCodeDTO;
|
||||
/**
|
||||
* Retry semantics for a failed turn — `manual` renders an inline Retry
|
||||
* button on the error bubble; `none`/`auto` render no button.
|
||||
*/
|
||||
retryAction?: RetryActionDTO;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
RetryActionDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import { resolveAssistantError } from '../resolveAssistantError';
|
||||
|
||||
const FALLBACK = 'Something went wrong. Please try again.';
|
||||
|
||||
function restError(status: number, code: string, message: string): AxiosError {
|
||||
const err = new AxiosError('Request failed');
|
||||
err.response = {
|
||||
status,
|
||||
data: { error: { code, message } },
|
||||
} as AxiosError['response'];
|
||||
return err;
|
||||
}
|
||||
|
||||
describe('resolveAssistantError', () => {
|
||||
describe('message resolution', () => {
|
||||
it('prefers code-specific FE copy over the backend message', () => {
|
||||
const err = restError(409, ErrorCodeDTO.thread_busy, 'raw backend phrasing');
|
||||
|
||||
const { message } = resolveAssistantError(err, FALLBACK);
|
||||
expect(message).toBe(
|
||||
'This conversation is still finishing a previous response. Give it a moment and try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls through to the backend message for a known code without FE copy', () => {
|
||||
const err = restError(
|
||||
400,
|
||||
ErrorCodeDTO.message_not_found,
|
||||
'No such message exists.',
|
||||
);
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
|
||||
message: 'No such message exists.',
|
||||
code: ErrorCodeDTO.message_not_found,
|
||||
retryAction: RetryActionDTO.none,
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when the error code is not in ErrorCodeDTO', () => {
|
||||
const err = restError(400, 'future_unknown_code', 'Backend-only message');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
code: undefined,
|
||||
retryAction: RetryActionDTO.none,
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limiting', () => {
|
||||
it('marks HTTP 429 responses as rate limited and non-retryable', () => {
|
||||
const err = restError(
|
||||
429,
|
||||
ErrorCodeDTO.hourly_message_limit,
|
||||
'Hourly limit reached.',
|
||||
);
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
|
||||
message: "You've reached the hourly message limit. Please try again later.",
|
||||
code: ErrorCodeDTO.hourly_message_limit,
|
||||
retryAction: RetryActionDTO.none,
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats known SSE rate-limit codes as rate limited', () => {
|
||||
const err = Object.assign(new Error('Daily token limit exceeded.'), {
|
||||
code: ErrorCodeDTO.daily_token_limit,
|
||||
});
|
||||
|
||||
const res = resolveAssistantError(err, FALLBACK);
|
||||
expect(res.isRateLimit).toBe(true);
|
||||
expect(res.retryAction).toBe(RetryActionDTO.none);
|
||||
});
|
||||
|
||||
it('marks 429 as rate limited even when the code is unknown', () => {
|
||||
const err = restError(429, 'future_unknown_code', 'Too many requests');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
code: undefined,
|
||||
retryAction: RetryActionDTO.none,
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryAction resolution', () => {
|
||||
it('honours an explicit retryAction from an SSE error event', () => {
|
||||
const err = Object.assign(new Error('Transient hiccup'), {
|
||||
code: ErrorCodeDTO.internal_error,
|
||||
retryAction: RetryActionDTO.auto,
|
||||
});
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.auto,
|
||||
);
|
||||
});
|
||||
|
||||
it('forces none for non-retryable permission errors', () => {
|
||||
const err = restError(403, ErrorCodeDTO.permission_denied, 'forbidden');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.none,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives manual for 409 conflicts', () => {
|
||||
const err = restError(409, ErrorCodeDTO.thread_has_active_execution, 'busy');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.manual,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives manual for 5xx responses', () => {
|
||||
const err = restError(503, 'future_unknown_code', 'unavailable');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.manual,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives manual for network failures with no response', () => {
|
||||
const err = new AxiosError('Network Error');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.manual,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives none for other 4xx responses', () => {
|
||||
const err = restError(400, 'future_unknown_code', 'bad request');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.none,
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to manual for non-Axios errors with no code', () => {
|
||||
expect(resolveAssistantError(new Error('boom'), FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.manual,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorCodeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import { resolveAssistantErrorMessage } from '../resolveAssistantErrorMessage';
|
||||
|
||||
const FALLBACK = 'Something went wrong. Please try again.';
|
||||
|
||||
describe('resolveAssistantErrorMessage', () => {
|
||||
it('returns backend message for a known error code', () => {
|
||||
const err = new AxiosError('Request failed');
|
||||
err.response = {
|
||||
status: 400,
|
||||
data: {
|
||||
error: {
|
||||
code: ErrorCodeDTO.thread_busy,
|
||||
message: 'This thread is busy. Try again shortly.',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'This thread is busy. Try again shortly.',
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when error code is not in ErrorCodeDTO', () => {
|
||||
const err = new AxiosError('Request failed');
|
||||
err.response = {
|
||||
status: 400,
|
||||
data: {
|
||||
error: {
|
||||
code: 'future_unknown_code',
|
||||
message: 'Backend-only message',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks HTTP 429 responses as rate limited', () => {
|
||||
const err = new AxiosError('Too many requests');
|
||||
err.response = {
|
||||
status: 429,
|
||||
data: {
|
||||
error: {
|
||||
code: ErrorCodeDTO.hourly_message_limit,
|
||||
message: 'Hourly limit reached.',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'Hourly limit reached.',
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses backend message for known SSE rate-limit error codes', () => {
|
||||
const err = Object.assign(new Error('Daily token limit exceeded.'), {
|
||||
code: ErrorCodeDTO.daily_token_limit,
|
||||
});
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'Daily token limit exceeded.',
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks 429 as rate limited even when error code is unknown', () => {
|
||||
const err = new AxiosError('Too many requests');
|
||||
err.response = {
|
||||
status: 429,
|
||||
data: {
|
||||
error: {
|
||||
code: 'future_unknown_code',
|
||||
message: 'Too many requests',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { isAxiosError } from 'axios';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
RetryActionDTO,
|
||||
type ErrorBodyDTO,
|
||||
type ErrorResponseDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
export interface AssistantErrorResolution {
|
||||
/** User-facing copy: code-specific FE copy → backend message → caller fallback. */
|
||||
message: string;
|
||||
/** Known backend error code, when one we recognise was supplied. */
|
||||
code?: ErrorCodeDTO;
|
||||
/**
|
||||
* Whether/how the failed action may be retried:
|
||||
* • `auto` — transient; the caller may silently re-attempt (capped).
|
||||
* • `manual` — surface a Retry affordance to the user.
|
||||
* • `none` — retrying would re-fail deterministically; offer nothing.
|
||||
*/
|
||||
retryAction: RetryActionDTO;
|
||||
/** Quota/limit error — callers hide the retry + feedback bar (retrying just re-limits). */
|
||||
isRateLimit: boolean;
|
||||
}
|
||||
|
||||
/** Quota/limit codes — surfaced as rate-limit errors (no retry, feedback bar hidden). */
|
||||
const RATE_LIMIT_ERROR_CODES = new Set<ErrorCodeDTO>([
|
||||
ErrorCodeDTO.rate_limit_override_exceeds_ceiling,
|
||||
ErrorCodeDTO.thread_message_limit,
|
||||
ErrorCodeDTO.connection_limit_exceeded,
|
||||
ErrorCodeDTO.hourly_message_limit,
|
||||
ErrorCodeDTO.daily_message_limit,
|
||||
ErrorCodeDTO.daily_token_limit,
|
||||
ErrorCodeDTO.daily_cost_limit,
|
||||
ErrorCodeDTO.budget_exceeded,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Codes whose retry would re-fail deterministically — permission/config/validation
|
||||
* failures. These force `retryAction: none` regardless of HTTP status.
|
||||
*/
|
||||
const NON_RETRYABLE_CODES = new Set<ErrorCodeDTO>([
|
||||
ErrorCodeDTO.permission_denied,
|
||||
ErrorCodeDTO.user_disabled,
|
||||
ErrorCodeDTO.org_disabled,
|
||||
ErrorCodeDTO.validation_error,
|
||||
ErrorCodeDTO.invalid_content_length,
|
||||
ErrorCodeDTO.invalid_fork_target,
|
||||
ErrorCodeDTO.missing_signoz_url,
|
||||
ErrorCodeDTO.invalid_signoz_url,
|
||||
ErrorCodeDTO.region_not_configured,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Code-specific, user-friendly copy. Takes precedence over the backend's raw
|
||||
* `error.message` so the user sees an actionable, consistent sentence rather
|
||||
* than internal phrasing. Codes absent here fall through to the backend message.
|
||||
*/
|
||||
const ERROR_CODE_COPY: Partial<Record<ErrorCodeDTO, string>> = {
|
||||
[ErrorCodeDTO.permission_denied]:
|
||||
"You don't have permission to do that. Contact your workspace admin if you think this is a mistake.",
|
||||
[ErrorCodeDTO.user_disabled]:
|
||||
'Your access to the AI assistant has been disabled. Contact your workspace admin to re-enable it.',
|
||||
[ErrorCodeDTO.org_disabled]:
|
||||
'The AI assistant is disabled for your organisation. An admin can enable it in settings.',
|
||||
[ErrorCodeDTO.thread_busy]:
|
||||
'This conversation is still finishing a previous response. Give it a moment and try again.',
|
||||
[ErrorCodeDTO.thread_has_active_execution]:
|
||||
'This conversation is still finishing a previous response. Give it a moment and try again.',
|
||||
[ErrorCodeDTO.hourly_message_limit]:
|
||||
"You've reached the hourly message limit. Please try again later.",
|
||||
[ErrorCodeDTO.daily_message_limit]:
|
||||
"You've reached the daily message limit. Please try again tomorrow.",
|
||||
[ErrorCodeDTO.daily_token_limit]:
|
||||
"You've reached today's usage limit. Please try again tomorrow.",
|
||||
[ErrorCodeDTO.daily_cost_limit]:
|
||||
"You've reached today's usage limit. Please try again tomorrow.",
|
||||
[ErrorCodeDTO.budget_exceeded]:
|
||||
"You've reached your usage budget. Contact your workspace admin to raise it.",
|
||||
[ErrorCodeDTO.thread_message_limit]:
|
||||
'This conversation has reached its length limit. Start a new conversation to continue.',
|
||||
[ErrorCodeDTO.connection_limit_exceeded]:
|
||||
'Too many active conversations right now. Close one and try again.',
|
||||
[ErrorCodeDTO.max_turns_exceeded]:
|
||||
'The assistant reached the maximum number of steps for this request. Try rephrasing or breaking it into smaller asks.',
|
||||
[ErrorCodeDTO.region_unreachable]:
|
||||
"Couldn't reach your region's services. Please try again in a moment.",
|
||||
[ErrorCodeDTO.region_not_configured]:
|
||||
'No region is configured for the AI assistant yet. An admin can set this up in settings.',
|
||||
[ErrorCodeDTO.mcp_unavailable]:
|
||||
'A required service is temporarily unavailable. Please try again shortly.',
|
||||
[ErrorCodeDTO.sandbox_unavailable]:
|
||||
'The execution environment is temporarily unavailable. Please try again shortly.',
|
||||
[ErrorCodeDTO.internal_error]:
|
||||
'Something went wrong on our end. Please try again.',
|
||||
};
|
||||
|
||||
function isErrorCodeDTO(code: string | undefined): code is ErrorCodeDTO {
|
||||
return (
|
||||
code !== undefined && (Object.values(ErrorCodeDTO) as string[]).includes(code)
|
||||
);
|
||||
}
|
||||
|
||||
function isRetryActionDTO(value: unknown): value is RetryActionDTO {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(Object.values(RetryActionDTO) as string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the structured error body out of either an Axios REST error or the
|
||||
* SSE error the streaming loop throws (a plain `Error` augmented with `code`).
|
||||
*/
|
||||
function getErrorBody(err: unknown): ErrorBodyDTO | null {
|
||||
if (isAxiosError(err)) {
|
||||
return (err.response?.data as ErrorResponseDTO | undefined)?.error ?? null;
|
||||
}
|
||||
|
||||
const code = (err as { code?: string } | undefined)?.code;
|
||||
const message = err instanceof Error ? err.message : undefined;
|
||||
if (!code || !message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { code: code as ErrorCodeDTO, message };
|
||||
}
|
||||
|
||||
function isRateLimit(code: ErrorCodeDTO | undefined, err: unknown): boolean {
|
||||
if (isAxiosError(err) && err.response?.status === 429) {
|
||||
return true;
|
||||
}
|
||||
return code !== undefined && RATE_LIMIT_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves how the failed action may be retried. The backend's explicit signal
|
||||
* (SSE `ErrorEventDTO.retryAction`) is authoritative; otherwise we derive it
|
||||
* from the rate-limit/non-retryable code sets and the HTTP status.
|
||||
*/
|
||||
function resolveRetryAction(
|
||||
err: unknown,
|
||||
code: ErrorCodeDTO | undefined,
|
||||
rateLimited: boolean,
|
||||
): RetryActionDTO {
|
||||
const explicit = (err as { retryAction?: unknown } | undefined)?.retryAction;
|
||||
if (isRetryActionDTO(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
if (rateLimited || (code !== undefined && NON_RETRYABLE_CODES.has(code))) {
|
||||
return RetryActionDTO.none;
|
||||
}
|
||||
|
||||
if (isAxiosError(err)) {
|
||||
const status = err.response?.status;
|
||||
// No response → network/timeout failure; retrying may well succeed.
|
||||
if (status === undefined || status === 408) {
|
||||
return RetryActionDTO.manual;
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
return RetryActionDTO.none;
|
||||
}
|
||||
if (status === 409 || status >= 500) {
|
||||
return RetryActionDTO.manual;
|
||||
}
|
||||
// Other 4xx (validation, bad request) re-fail deterministically.
|
||||
return RetryActionDTO.none;
|
||||
}
|
||||
|
||||
// Non-Axios transport/parse error with no code — let the user retry.
|
||||
return RetryActionDTO.manual;
|
||||
}
|
||||
|
||||
function resolveMessage(
|
||||
code: ErrorCodeDTO | undefined,
|
||||
body: ErrorBodyDTO | null,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (code !== undefined && ERROR_CODE_COPY[code]) {
|
||||
return ERROR_CODE_COPY[code] as string;
|
||||
}
|
||||
// Trust the backend's message only for codes we recognise — never surface
|
||||
// raw text for unknown codes (could be an internal stack trace).
|
||||
if (code !== undefined && body?.message.trim()) {
|
||||
return body.message.trim();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single resolution point for both SSE and REST assistant errors. Maps the
|
||||
* error onto user-facing copy plus retry semantics, degrading gracefully for
|
||||
* unknown codes (falls back to `fallback` + a `manual` retry where sensible).
|
||||
*/
|
||||
export function resolveAssistantError(
|
||||
err: unknown,
|
||||
fallback: string,
|
||||
): AssistantErrorResolution {
|
||||
const body = getErrorBody(err);
|
||||
const code = isErrorCodeDTO(body?.code) ? body?.code : undefined;
|
||||
const rateLimited = isRateLimit(code, err);
|
||||
|
||||
return {
|
||||
message: resolveMessage(code, body, fallback),
|
||||
code,
|
||||
retryAction: resolveRetryAction(err, code, rateLimited),
|
||||
isRateLimit: rateLimited,
|
||||
};
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { isAxiosError } from 'axios';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
type ErrorBodyDTO,
|
||||
type ErrorResponseDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
export interface AssistantErrorResolution {
|
||||
message: string;
|
||||
isRateLimit: boolean;
|
||||
}
|
||||
|
||||
function isErrorCodeDTO(code: string | undefined): code is ErrorCodeDTO {
|
||||
return (
|
||||
code !== undefined && (Object.values(ErrorCodeDTO) as string[]).includes(code)
|
||||
);
|
||||
}
|
||||
|
||||
const RATE_LIMIT_ERROR_CODES = new Set<ErrorCodeDTO>([
|
||||
ErrorCodeDTO.rate_limit_override_exceeds_ceiling,
|
||||
ErrorCodeDTO.thread_message_limit,
|
||||
ErrorCodeDTO.connection_limit_exceeded,
|
||||
ErrorCodeDTO.hourly_message_limit,
|
||||
ErrorCodeDTO.daily_message_limit,
|
||||
ErrorCodeDTO.daily_token_limit,
|
||||
ErrorCodeDTO.daily_cost_limit,
|
||||
ErrorCodeDTO.budget_exceeded,
|
||||
]);
|
||||
|
||||
function isRateLimitError(code: string | undefined, err: unknown): boolean {
|
||||
if (isAxiosError(err) && err.response?.status === 429) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isErrorCodeDTO(code) && RATE_LIMIT_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
function getErrorBody(err: unknown): ErrorBodyDTO | null {
|
||||
if (isAxiosError(err)) {
|
||||
return (err.response?.data as ErrorResponseDTO | undefined)?.error ?? null;
|
||||
}
|
||||
|
||||
const code = (err as { code?: string } | undefined)?.code;
|
||||
const message = err instanceof Error ? err.message : undefined;
|
||||
if (!code || !message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { code: code as ErrorCodeDTO, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses `error.message` when `error.code` is a known `ErrorCodeDTO`;
|
||||
* otherwise returns `fallback`.
|
||||
*/
|
||||
export function resolveAssistantErrorMessage(
|
||||
err: unknown,
|
||||
fallback: string,
|
||||
): AssistantErrorResolution {
|
||||
const body = getErrorBody(err);
|
||||
const isRateLimit = isRateLimitError(body?.code, err);
|
||||
|
||||
if (body && isErrorCodeDTO(body.code) && body.message.trim()) {
|
||||
return {
|
||||
message: body.message.trim(),
|
||||
isRateLimit,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: fallback, isRateLimit: Boolean(isRateLimit) };
|
||||
}
|
||||
@@ -26,7 +26,12 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
|
||||
|
||||
describe('Should check if the edit alert channel is properly displayed', () => {
|
||||
beforeEach(() => {
|
||||
render(<EditAlertChannels initialValue={editAlertChannelInitialValue} />);
|
||||
render(
|
||||
<EditAlertChannels
|
||||
channelId="3"
|
||||
initialValue={editAlertChannelInitialValue}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import EditAlertChannels from 'container/EditAlertChannels';
|
||||
import { editAlertChannelInitialValue } from 'mocks-server/__mockdata__/alerts';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
__esModule: true,
|
||||
useNotifications: jest.fn(() => ({
|
||||
notifications: { success: jest.fn(), error: jest.fn() },
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
|
||||
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
|
||||
}));
|
||||
|
||||
interface EditRequest {
|
||||
id: string;
|
||||
body: { name: string; slack_configs: { send_resolved: boolean }[] };
|
||||
}
|
||||
|
||||
// Captures the PUT /channels/:id request the edit form fires, so assertions can
|
||||
// run against the real HTTP payload instead of a hand-mocked api client.
|
||||
function mockEditChannel(): { calls: EditRequest[] } {
|
||||
const result: { calls: EditRequest[] } = { calls: [] };
|
||||
server.use(
|
||||
rest.put('http://localhost/api/v1/channels/:id', async (req, res, ctx) => {
|
||||
result.calls.push({
|
||||
id: req.params.id as string,
|
||||
body: await req.json(),
|
||||
});
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: 'channel updated' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('EditAlertChannels save', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('sends the channelId in the edit request (regression: empty id)', async () => {
|
||||
const edit = mockEditChannel();
|
||||
render(
|
||||
<EditAlertChannels
|
||||
channelId="3"
|
||||
initialValue={editAlertChannelInitialValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() => expect(edit.calls).toHaveLength(1));
|
||||
expect(edit.calls[0].id).toBe('3');
|
||||
});
|
||||
|
||||
it('persists send_resolved toggle in the edit request', async () => {
|
||||
const edit = mockEditChannel();
|
||||
render(
|
||||
<EditAlertChannels
|
||||
channelId="3"
|
||||
initialValue={editAlertChannelInitialValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const sendResolved = screen.getByTestId('field-send-resolved-checkbox');
|
||||
expect(sendResolved).toBeChecked();
|
||||
|
||||
await user.click(sendResolved);
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() => expect(edit.calls).toHaveLength(1));
|
||||
expect(edit.calls[0].id).toBe('3');
|
||||
expect(edit.calls[0].body.slack_configs[0].send_resolved).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -413,14 +413,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
const isPanelEditorV2 = routeKey === 'DASHBOARD_PANEL_EDITOR';
|
||||
|
||||
const renderFullScreen =
|
||||
pathname === ROUTES.GET_STARTED ||
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname === ROUTES.GET_STARTED_WITH_CLOUD ||
|
||||
pathname === ROUTES.GET_STARTED_APPLICATION_MONITORING ||
|
||||
pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING ||
|
||||
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
|
||||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
|
||||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
|
||||
isPublicDashboard ||
|
||||
isPanelEditorV2;
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import APIError from 'types/api/error';
|
||||
|
||||
function EditAlertChannels({
|
||||
initialValue,
|
||||
channelId: id,
|
||||
}: EditAlertChannelsProps): JSX.Element {
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('channels');
|
||||
@@ -53,11 +54,6 @@ function EditAlertChannels({
|
||||
const [testingState, setTestingState] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
// Extract channelId from URL pathname since useParams doesn't work in nested routing
|
||||
const { pathname } = window.location;
|
||||
const channelIdMatch = pathname.match(/\/settings\/channels\/edit\/([^/]+)/);
|
||||
const id = channelIdMatch ? channelIdMatch[1] : '';
|
||||
|
||||
const [type, setType] = useState<ChannelType>(
|
||||
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
|
||||
);
|
||||
@@ -520,6 +516,7 @@ interface EditAlertChannelsProps {
|
||||
initialValue: {
|
||||
[x: string]: unknown;
|
||||
};
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export default EditAlertChannels;
|
||||
|
||||
@@ -136,6 +136,7 @@ function FormAlertChannels({
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
data-testid="save-channel-button"
|
||||
disabled={savingState}
|
||||
loading={savingState}
|
||||
type="primary"
|
||||
@@ -144,6 +145,7 @@ function FormAlertChannels({
|
||||
{t('button_save_channel')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="test-channel-button"
|
||||
disabled={testingState}
|
||||
loading={testingState}
|
||||
onClick={(): void => onTestHandler(type)}
|
||||
@@ -151,6 +153,7 @@ function FormAlertChannels({
|
||||
{t('button_test_channel')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="return-button"
|
||||
onClick={(): void => {
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
}}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
.full-screen-header-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px 0;
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.brand-logo-name {
|
||||
font-family: 'Work Sans', sans-serif;
|
||||
font-size: 24px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import history from 'lib/history';
|
||||
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import './FullScreenHeader.styles.scss';
|
||||
|
||||
export default function FullScreenHeader({
|
||||
overrideRoute,
|
||||
}: {
|
||||
overrideRoute?: string;
|
||||
}): React.ReactElement {
|
||||
const handleLogoClick = (): void => {
|
||||
history.push(overrideRoute || '/');
|
||||
};
|
||||
return (
|
||||
<div className="full-screen-header-container">
|
||||
<div className="brand-logo" onClick={handleLogoClick}>
|
||||
<img src={signozBrandLogoUrl} alt="SigNoz" />
|
||||
|
||||
<div className="brand-logo-name">SigNoz</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FullScreenHeader.defaultProps = {
|
||||
overrideRoute: '/',
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
.llmObservability {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
18
frontend/src/container/LLMObservability/LLMObservability.tsx
Normal file
18
frontend/src/container/LLMObservability/LLMObservability.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import styles from './LLMObservability.module.scss';
|
||||
|
||||
function LLMObservability(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.llmObservability} data-testid="llm-observability-page">
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>LLM Observability</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Monitor and analyze your LLM usage, costs, and performance
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservability;
|
||||
@@ -0,0 +1,30 @@
|
||||
.llmObservabilityModelPricing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import ModelCostTabPanel from './ModelCostTabPanel';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
|
||||
function LLMObservabilityModelPricing(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.llmObservabilityModelPricing}
|
||||
data-testid="llm-observability-model-pricing-page"
|
||||
>
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<Typography.Text as="h1" size="large" weight="semibold">
|
||||
Configuration
|
||||
</Typography.Text>
|
||||
<Typography.Text color="muted">
|
||||
Model pricing and cost estimation settings
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
// Model costs is the only enabled tab for now, so default to it. When
|
||||
// the unpriced-models tab lands, this can become a URL-backed param.
|
||||
defaultValue="model-costs"
|
||||
items={[
|
||||
{
|
||||
key: 'model-costs',
|
||||
label: 'Model costs',
|
||||
children: <ModelCostTabPanel />,
|
||||
},
|
||||
{
|
||||
// Unpriced-models tab lands in a later PR.
|
||||
key: 'unpriced-models',
|
||||
label: 'Unpriced models',
|
||||
disabled: true,
|
||||
children: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservabilityModelPricing;
|
||||
@@ -0,0 +1,7 @@
|
||||
.pageError {
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
border-radius: var(--radius-2);
|
||||
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
|
||||
color: var(--text-cherry-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useTableParams } from 'components/TanStackTableView';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LIMIT_KEY, PAGE_KEY, PAGE_SIZE } from '../constants';
|
||||
import styles from './ModelCostTabPanel.module.scss';
|
||||
import ModelCostsTable from './components/ModelCostsTable';
|
||||
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
function ModelCostTabPanel(): JSX.Element {
|
||||
const { page, limit } = useTableParams(
|
||||
{ page: PAGE_KEY, limit: LIMIT_KEY },
|
||||
{ page: 1, limit: PAGE_SIZE },
|
||||
);
|
||||
|
||||
// Search + source filters are intentionally omitted for now — the list API
|
||||
// doesn't honour them yet. They'll be reintroduced here once it does.
|
||||
const listParams: ListLLMPricingRulesParams = {
|
||||
offset: (page - 1) * limit,
|
||||
limit,
|
||||
};
|
||||
|
||||
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
|
||||
|
||||
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
|
||||
() => data?.data?.items || [],
|
||||
[data],
|
||||
);
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError && (
|
||||
<div className={styles.pageError} role="alert">
|
||||
Failed to load pricing rules. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Read-only listing. Edit/Add wiring + the drawer land in the next PR. */}
|
||||
<ModelCostsTable
|
||||
rules={rules}
|
||||
isLoading={isLoading}
|
||||
total={total}
|
||||
selectedRuleId={null}
|
||||
canManage={false}
|
||||
onEdit={(): void => undefined}
|
||||
onDelete={(): void => undefined}
|
||||
/>
|
||||
|
||||
<footer>
|
||||
<Typography.Text color="muted" size="small">
|
||||
All prices per 1M tokens (USD)
|
||||
</Typography.Text>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostTabPanel;
|
||||
@@ -0,0 +1,8 @@
|
||||
.actionButton {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './ModelCostActionsMenu.module.scss';
|
||||
|
||||
interface ModelCostActionsMenuProps {
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO;
|
||||
canManage: boolean;
|
||||
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
|
||||
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
|
||||
}
|
||||
|
||||
// Per-row kebab menu for the model-costs table. Only manage users get actions
|
||||
// (Edit + Delete); view-only users have nothing to act on, so the cell stays
|
||||
// empty rather than showing a single-item menu.
|
||||
function ModelCostActionsMenu({
|
||||
rule,
|
||||
canManage,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ModelCostActionsMenuProps): JSX.Element | null {
|
||||
const menuItems = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
onClick: (): void => onEdit(rule),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
danger: true,
|
||||
onClick: (): void => onDelete(rule),
|
||||
},
|
||||
],
|
||||
[onEdit, onDelete, rule],
|
||||
);
|
||||
|
||||
if (!canManage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.actionButton}
|
||||
testId={`model-cost-actions-${rule.id}`}
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostActionsMenu;
|
||||
@@ -0,0 +1,20 @@
|
||||
.modelCostsTable {
|
||||
margin-top: var(--spacing-8);
|
||||
--tanstack-table-row-height: 48px;
|
||||
height: calc(100vh - 250px);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(table) tbody tr {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.modelCostsEmpty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: var(--spacing-8);
|
||||
min-height: 400px;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useMemo } from 'react';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
|
||||
import {
|
||||
LIMIT_KEY,
|
||||
PAGE_KEY,
|
||||
PAGE_SIZE,
|
||||
SKELETON_ROW_COUNT,
|
||||
} from '../../../constants';
|
||||
import styles from './ModelCostsTable.module.scss';
|
||||
import { getModelCostsColumns } from './TableConfig';
|
||||
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface ModelCostsTableProps {
|
||||
rules: LlmpricingruletypesLLMPricingRuleDTO[];
|
||||
isLoading: boolean;
|
||||
total: number;
|
||||
selectedRuleId: string | null;
|
||||
canManage: boolean;
|
||||
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
|
||||
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
|
||||
}
|
||||
|
||||
// The table owns its own pagination URL state (page/limit) via enableQueryParams;
|
||||
// ModelCostsTab reads the same keys to build the list request. Virtual scroll is
|
||||
// disabled: a plain table renders fine at our page sizes (up to 100 rows) and the
|
||||
// fixed-height scroll viewport (.modelCostsTable) keeps large pages scrolling
|
||||
// inside the table.
|
||||
function ModelCostsTable({
|
||||
rules,
|
||||
isLoading,
|
||||
total,
|
||||
selectedRuleId,
|
||||
canManage,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ModelCostsTableProps): JSX.Element {
|
||||
const columns = useMemo(
|
||||
() => getModelCostsColumns({ canManage, onEdit, onDelete }),
|
||||
[canManage, onEdit, onDelete],
|
||||
);
|
||||
|
||||
if (!isLoading && rules.length === 0) {
|
||||
return (
|
||||
<div className={styles.modelCostsEmpty} data-testid="model-costs-empty">
|
||||
No model costs yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackTable<LlmpricingruletypesLLMPricingRuleDTO>
|
||||
className={styles.modelCostsTable}
|
||||
data={rules}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
skeletonRowCount={SKELETON_ROW_COUNT}
|
||||
getRowKey={(row): string => row.id}
|
||||
isRowActive={(row): boolean => row.id === selectedRuleId}
|
||||
disableVirtualScroll
|
||||
testId="model-costs-table"
|
||||
enableQueryParams={{ page: PAGE_KEY, limit: LIMIT_KEY }}
|
||||
pagination={{
|
||||
total,
|
||||
defaultLimit: PAGE_SIZE,
|
||||
showTotalCount: true,
|
||||
totalCountLabel: 'models',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostsTable;
|
||||
@@ -0,0 +1 @@
|
||||
export { getModelCostsColumns } from './table.config';
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { startCase } from 'lodash-es';
|
||||
|
||||
import styles from './tableConfig.module.scss';
|
||||
import ModelCostActionsMenu from '../ModelCostActionsMenu';
|
||||
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
getCanonicalId,
|
||||
getExtraBuckets,
|
||||
getRelativeLastSeen,
|
||||
getSourceLabel,
|
||||
} from '../../../../utils';
|
||||
|
||||
interface ColumnsConfig {
|
||||
canManage: boolean;
|
||||
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
|
||||
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
|
||||
}
|
||||
|
||||
// Column definitions for the model-costs TanStackTable. Sorting is intentionally
|
||||
// off across the board — the list API only accepts offset/limit, so there's no
|
||||
// server-side ordering to back a sortable header yet.
|
||||
export function getModelCostsColumns({
|
||||
canManage,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ColumnsConfig): TableColumnDef<LlmpricingruletypesLLMPricingRuleDTO>[] {
|
||||
return [
|
||||
{
|
||||
id: 'model',
|
||||
header: 'Model',
|
||||
accessorFn: (row): string => row.modelName ?? '',
|
||||
// Flexes to absorb spare width alongside Extra buckets so the row fills
|
||||
// the container instead of leaving a gap on the right.
|
||||
width: { min: 240, default: '100%' },
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<div className={styles.modelCell}>
|
||||
<Typography.Text
|
||||
weight="semibold"
|
||||
truncate={1}
|
||||
testId={`model-cell-name-${row.id}`}
|
||||
>
|
||||
{row.modelName}
|
||||
</Typography.Text>
|
||||
|
||||
<Typography.Text truncate={1}>{getCanonicalId(row)}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'provider',
|
||||
header: 'Provider',
|
||||
accessorKey: 'provider',
|
||||
width: { min: 140 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): string => row.provider ?? '',
|
||||
},
|
||||
{
|
||||
id: 'input',
|
||||
header: 'Input / 1M',
|
||||
width: { min: 120 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Typography.Text>
|
||||
{formatPricePerMillion(row.pricing?.input)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
header: 'Output / 1M',
|
||||
width: { min: 120 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Typography.Text>
|
||||
{formatPricePerMillion(row.pricing?.output)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'extraBuckets',
|
||||
header: 'Extra buckets',
|
||||
width: { min: 200, default: '100%' },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => {
|
||||
const buckets = getExtraBuckets(row);
|
||||
if (buckets.length === 0) {
|
||||
return (
|
||||
<Typography.Text color="muted" as="span">
|
||||
—
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.extraBuckets}>
|
||||
{buckets.map((bucket) => (
|
||||
<Badge
|
||||
key={bucket.key}
|
||||
color="vanilla"
|
||||
variant="outline"
|
||||
className={styles.extraBucketsChip}
|
||||
>
|
||||
<Typography.Text as="span" size="small">
|
||||
{startCase(bucket.key)}
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" size="small" weight="semibold">
|
||||
{formatPricePerMillion(bucket.pricePerMillion)}
|
||||
</Typography.Text>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
header: 'Source',
|
||||
width: { min: 130 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Badge
|
||||
color={row.isOverride ? 'amber' : 'robin'}
|
||||
variant="outline"
|
||||
className={styles.sourceBadge}
|
||||
data-testid={`source-badge-${row.id}`}
|
||||
>
|
||||
{getSourceLabel(row)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'lastSeen',
|
||||
header: 'Last seen',
|
||||
width: { min: 120 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): string => getRelativeLastSeen(row),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
width: { fixed: '56px', ignoreLastColumnFill: true },
|
||||
pin: 'right',
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element | null => (
|
||||
<ModelCostActionsMenu
|
||||
rule={row}
|
||||
canManage={canManage}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.modelCell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.extraBuckets {
|
||||
display: flex;
|
||||
// Keep chips on a single line so the row stays at the table's fixed row
|
||||
// height; the column flexes to 100% so there's room for both.
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--spacing-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.extraBucketsChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sourceBadge {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ModelCostsTable';
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ModelCostTabPanel';
|
||||
@@ -0,0 +1,6 @@
|
||||
export const PAGE_SIZE = 20;
|
||||
|
||||
export const PAGE_KEY = 'page';
|
||||
export const LIMIT_KEY = 'limit';
|
||||
|
||||
export const SKELETON_ROW_COUNT = PAGE_SIZE;
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ExtraBucket {
|
||||
key: string;
|
||||
pricePerMillion: number;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import type { ExtraBucket } from './types';
|
||||
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const getRelativeTime = (
|
||||
timestamp: string | number | Date | null | undefined,
|
||||
): string => {
|
||||
const parsed = timestamp != null ? dayjs(timestamp) : null;
|
||||
return parsed?.isValid() ? parsed.fromNow() : '—';
|
||||
};
|
||||
|
||||
// ─── Display helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
export const formatPricePerMillion = (value: number | undefined): string => {
|
||||
if (value === undefined || value === null) {
|
||||
return '—';
|
||||
}
|
||||
// 2dp is enough for per-1M pricing. we can update this later we models have sub-cent pricing.
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
export const getExtraBuckets = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): ExtraBucket[] => {
|
||||
const cache = rule.pricing?.cache;
|
||||
if (!cache) {
|
||||
return [];
|
||||
}
|
||||
const buckets: ExtraBucket[] = [];
|
||||
if (typeof cache.read === 'number' && cache.read > 0) {
|
||||
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
|
||||
}
|
||||
if (typeof cache.write === 'number' && cache.write > 0) {
|
||||
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
|
||||
}
|
||||
return buckets;
|
||||
};
|
||||
|
||||
export const getSourceLabel = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): 'Auto' | 'User override' => (rule.isOverride ? 'User override' : 'Auto');
|
||||
|
||||
export const getRelativeLastSeen = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): string => getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
|
||||
|
||||
// Canonical id shown under the model name, e.g. "openai:gpt-4o". Both segments
|
||||
// are lower-cased so the id is consistently normalised (providers/models can
|
||||
// arrive with mixed casing).
|
||||
export const getCanonicalId = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): string => {
|
||||
const provider = rule.provider?.trim().toLowerCase() || 'unknown';
|
||||
const model = rule.modelName?.trim().toLowerCase() || 'unknown';
|
||||
return `${provider}:${model}`;
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
.members-settings {
|
||||
.members-settings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.members-settings {
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user