mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-01 18:50:35 +01:00
Compare commits
5 Commits
ci/testsci
...
refactor/f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7099f14d97 | ||
|
|
b2d8a44991 | ||
|
|
d304f2b081 | ||
|
|
19cc5c6665 | ||
|
|
e43ce9f57f |
24
.github/workflows/e2eci.yaml
vendored
24
.github/workflows/e2eci.yaml
vendored
@@ -75,30 +75,6 @@ jobs:
|
||||
docker rm pw
|
||||
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
|
||||
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
|
||||
# Restore-only: the cacheci workflow owns cache saves. Seeds the
|
||||
# BuildKit cache mounts so the in-test image build is incremental.
|
||||
- name: restore
|
||||
id: restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/cacheci
|
||||
key: tests-primary
|
||||
restore-keys: |
|
||||
tests-secondary
|
||||
- name: inject
|
||||
if: steps.restore.outputs.cache-matched-key != ''
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/inject.Dockerfile" <<'EOF'
|
||||
FROM busybox:1.37
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/pnpm/store \
|
||||
--mount=type=bind,target=/restored \
|
||||
tar -xf /restored/go-build.tar -C /root/.cache/go-build && \
|
||||
tar -xf /restored/go-mod.tar -C /go/pkg/mod && \
|
||||
tar -xf /restored/pnpm-store.tar -C /pnpm/store
|
||||
EOF
|
||||
docker build -f "$RUNNER_TEMP/inject.Dockerfile" "$RUNNER_TEMP/cacheci"
|
||||
- name: bring-up-stack
|
||||
run: |
|
||||
cd tests && \
|
||||
|
||||
24
.github/workflows/integrationci.yaml
vendored
24
.github/workflows/integrationci.yaml
vendored
@@ -110,30 +110,6 @@ jobs:
|
||||
sudo mv chromedriver-linux64/chromedriver /usr/local/bin/chromedriver
|
||||
chromedriver -version
|
||||
google-chrome-stable --version
|
||||
# Restore-only: the cacheci workflow owns cache saves. Seeds the
|
||||
# BuildKit cache mounts so the in-test image build is incremental.
|
||||
- name: restore
|
||||
id: restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/cacheci
|
||||
key: tests-primary
|
||||
restore-keys: |
|
||||
tests-secondary
|
||||
- name: inject
|
||||
if: steps.restore.outputs.cache-matched-key != ''
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/inject.Dockerfile" <<'EOF'
|
||||
FROM busybox:1.37
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/pnpm/store \
|
||||
--mount=type=bind,target=/restored \
|
||||
tar -xf /restored/go-build.tar -C /root/.cache/go-build && \
|
||||
tar -xf /restored/go-mod.tar -C /go/pkg/mod && \
|
||||
tar -xf /restored/pnpm-store.tar -C /pnpm/store
|
||||
EOF
|
||||
docker build -f "$RUNNER_TEMP/inject.Dockerfile" "$RUNNER_TEMP/cacheci"
|
||||
- name: run
|
||||
run: |
|
||||
cd tests && \
|
||||
|
||||
4
Makefile
4
Makefile
@@ -209,8 +209,8 @@ py-lint: ## Run ruff check across the shared tests project
|
||||
@cd tests && uv run ruff check --fix .
|
||||
|
||||
.PHONY: py-test-setup
|
||||
py-test-setup: ## Bring up the shared SigNoz backend used by integration and e2e tests, rebuilding signoz from the current sources
|
||||
@cd tests && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no integration/bootstrap/setup.py::test_setup
|
||||
py-test-setup: ## Bring up the shared SigNoz backend used by integration and e2e tests
|
||||
@cd tests && uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/bootstrap/setup.py::test_setup
|
||||
|
||||
.PHONY: py-test-teardown
|
||||
py-test-teardown: ## Tear down the shared SigNoz backend
|
||||
|
||||
@@ -4,13 +4,9 @@ ARG OS="linux"
|
||||
ARG TARGETARCH
|
||||
ARG ZEUSURL
|
||||
|
||||
# HOME comes from the build user, not the image config; declare it so the
|
||||
# /root paths below trace to it.
|
||||
ENV HOME=/root
|
||||
|
||||
# This path is important for stacktraces
|
||||
WORKDIR $GOPATH/src/github.com/signoz/signoz
|
||||
WORKDIR $HOME
|
||||
WORKDIR /root
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
@@ -18,36 +14,23 @@ RUN set -eux; \
|
||||
g++ \
|
||||
gcc \
|
||||
libc6-dev \
|
||||
make \
|
||||
pkg-config \
|
||||
; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Keep the literal cache-mount targets below in sync with these. The caches
|
||||
# are shared with Dockerfile.with-web.integration (same target paths).
|
||||
ENV GOCACHE=$HOME/.cache/go-build
|
||||
ENV GOMODCACHE=$GOPATH/pkg/mod
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
RUN go mod download
|
||||
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
|
||||
# Invoked directly instead of via make so Makefile changes don't invalidate
|
||||
# this layer; the Makefile's git-derived ldflags resolve to empty in here
|
||||
# anyway (.git is dockerignored).
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
GOARCH=${TARGETARCH} GOOS=${OS} go build -C ./cmd/enterprise -race -tags timetzdata -o /root/signoz \
|
||||
-ldflags "-s -w \
|
||||
-X github.com/SigNoz/signoz/pkg/version.version=integration \
|
||||
-X github.com/SigNoz/signoz/pkg/version.variant=enterprise \
|
||||
-X github.com/SigNoz/signoz/ee/zeus.url=${ZEUSURL} \
|
||||
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=${ZEUSURL}/api/v1"
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
FROM node:22-bookworm AS build
|
||||
|
||||
WORKDIR /opt/
|
||||
|
||||
# HOME comes from the build user, not the image config.
|
||||
ENV HOME=/root
|
||||
# pnpm's store lives at $PNPM_HOME/store — a dedicated directory pnpm
|
||||
# manages. Keep the literal cache-mount targets below in sync.
|
||||
ENV PNPM_HOME=/pnpm
|
||||
ENV NODE_OPTIONS=--max-old-space-size=8192
|
||||
|
||||
RUN CI=1 npm i -g pnpm@10
|
||||
|
||||
# pnpm fetch resolves from the lockfile alone and runs no lifecycle scripts;
|
||||
# the repo's postinstall needs source files that are not copied yet.
|
||||
COPY ./frontend/package.json ./frontend/pnpm-lock.yaml ./frontend/pnpm-workspace.yaml ./
|
||||
RUN --mount=type=cache,target=/pnpm/store CI=1 pnpm fetch
|
||||
|
||||
COPY ./frontend/ ./
|
||||
RUN --mount=type=cache,target=/pnpm/store CI=1 pnpm install --offline
|
||||
ENV NODE_OPTIONS=--max-old-space-size=8192
|
||||
RUN CI=1 npm i -g pnpm@10
|
||||
RUN CI=1 pnpm install
|
||||
RUN CI=1 pnpm build
|
||||
|
||||
FROM golang:1.25-bookworm
|
||||
@@ -26,13 +13,9 @@ ARG OS="linux"
|
||||
ARG TARGETARCH
|
||||
ARG ZEUSURL
|
||||
|
||||
# HOME comes from the build user, not the image config; declare it so the
|
||||
# /root paths below trace to it.
|
||||
ENV HOME=/root
|
||||
|
||||
# This path is important for stacktraces
|
||||
WORKDIR $GOPATH/src/github.com/signoz/signoz
|
||||
WORKDIR $HOME
|
||||
WORKDIR /root
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
@@ -40,36 +23,23 @@ RUN set -eux; \
|
||||
g++ \
|
||||
gcc \
|
||||
libc6-dev \
|
||||
make \
|
||||
pkg-config \
|
||||
; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Keep the literal cache-mount targets below in sync with these. The caches
|
||||
# are shared with Dockerfile.integration (same target paths).
|
||||
ENV GOCACHE=$HOME/.cache/go-build
|
||||
ENV GOMODCACHE=$GOPATH/pkg/mod
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
RUN go mod download
|
||||
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
|
||||
# Invoked directly instead of via make so Makefile changes don't invalidate
|
||||
# this layer; the Makefile's git-derived ldflags resolve to empty in here
|
||||
# anyway (.git is dockerignored).
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
GOARCH=${TARGETARCH} GOOS=${OS} go build -C ./cmd/enterprise -race -tags timetzdata -o /root/signoz \
|
||||
-ldflags "-s -w \
|
||||
-X github.com/SigNoz/signoz/pkg/version.version=integration \
|
||||
-X github.com/SigNoz/signoz/pkg/version.variant=enterprise \
|
||||
-X github.com/SigNoz/signoz/ee/zeus.url=${ZEUSURL} \
|
||||
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=${ZEUSURL}/api/v1"
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
COPY --from=build /opt/build ./web/
|
||||
|
||||
|
||||
@@ -7430,8 +7430,6 @@ components:
|
||||
- below
|
||||
- equal
|
||||
- not_equal
|
||||
- above_or_equal
|
||||
- below_or_equal
|
||||
- outside_bounds
|
||||
type: string
|
||||
RuletypesCumulativeSchedule:
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
# PromQL Serving — clickhouseprometheusv2
|
||||
|
||||
This document is the subsystem context for `pkg/prometheus/clickhouseprometheusv2`,
|
||||
the second-generation ClickHouse-backed Prometheus provider. It explains why the
|
||||
package exists, the correctness constraints that shaped it, and how each fetch
|
||||
reduction is proven not to change results. Any change to the provider must keep
|
||||
these invariants; if a change would violate one, it must be flagged and
|
||||
discussed.
|
||||
|
||||
---
|
||||
|
||||
## Why a second provider
|
||||
|
||||
The v1 provider (`pkg/prometheus/clickhouseprometheus`) serves the promql engine
|
||||
through the remote-read protobuf adapter: every raw sample of a query's union
|
||||
window is fetched, serialized, and handed to the engine. The cost is a function
|
||||
of ingested data, not of the question asked — which is how a dashboard of PromQL
|
||||
panels can take an instance down.
|
||||
|
||||
In v2 the stock promql engine evaluates over a native `storage.Querier`: no
|
||||
translation layer, per-selector fetch windows, and fetch reductions that are
|
||||
provably invisible to the engine.
|
||||
|
||||
**The core constraint: every reduction either preserves engine semantics exactly
|
||||
or is not performed.** A PromQL result that differs from upstream Prometheus is
|
||||
a lost user. The conformance suite
|
||||
(`tests/integration/tests/promqlconformance/`) replays Prometheus' own test
|
||||
corpus against both providers and is the arbiter.
|
||||
|
||||
---
|
||||
|
||||
## Series lookup
|
||||
|
||||
Matchers resolve to series once per selector (`selectSeries`) against the series
|
||||
tables, which hold one row per (fingerprint, bucket) at 1h/6h/1d/1w
|
||||
granularities. Table selection and window rounding delegate to the shared
|
||||
metrics schema package (`pkg/telemetryschema/metricstelemetryschema`); the
|
||||
window start rounds down to the bucket boundary so a window beginning mid-bucket
|
||||
still matches the bucket's row.
|
||||
|
||||
How matchers become SQL is documented at `applySeriesConditions`. The rules that
|
||||
carry semantics:
|
||||
|
||||
- `__name__` matchers (all four types) translate to the `metric_name` column.
|
||||
- Every other matcher becomes a `JSONExtractString` condition on the labels
|
||||
column. An equality matcher against `""` matches series *without* the label,
|
||||
mirroring PromQL, because `JSONExtractString` returns `""` for missing keys.
|
||||
- Regexes are anchored (`^(?:...)$`) before they reach `match()`: PromQL
|
||||
matchers match the whole value, ClickHouse `match()` searches for a
|
||||
substring.
|
||||
- The series-lookup upper bound is inclusive (`unix_milli <= end`) because the
|
||||
exporter floors registration rows to the bucket start: a series first
|
||||
registered in the bucket beginning exactly at `end` would otherwise be
|
||||
invisible while its samples are in range.
|
||||
|
||||
Empty-valued labels come off at this boundary: an empty value means "label
|
||||
absent" in Prometheus, but stored attribute JSON can carry them.
|
||||
|
||||
---
|
||||
|
||||
## Sample fetch
|
||||
|
||||
Samples are fetched per selector using the engine's per-selector hints, not the
|
||||
query-wide union window — `foo / foo offset 1d` reads two narrow windows
|
||||
instead of the widest one twice.
|
||||
|
||||
**Last-sample-per-step reduction.** Instant selectors of subquery-free queries
|
||||
fetch only the last sample per step bucket. The engine resolves an instant
|
||||
selector at each grid timestamp `t` to the latest sample in the left-open
|
||||
lookback window `(t − lookback, t]`. Buckets anchor at the selector's first
|
||||
evaluation timestamp — recovered from the hints as
|
||||
`hints.Start + lookback − 1ms`, the inverse of how the engine derives
|
||||
`hints.Start` — so bucket boundaries coincide with evaluation timestamps, and a
|
||||
non-final sample of a bucket can never be the latest sample in
|
||||
`(t − lookback, t]` for any grid `t`. Real timestamps are preserved, so the
|
||||
engine's own lookback and staleness handling stay exact.
|
||||
|
||||
Range selectors always fetch raw — every sample feeds the range function. The
|
||||
subquery-free proof travels in the context as `prometheus.QueryTraits`, because
|
||||
subquery selectors evaluate at the subquery's step while the hints carry the
|
||||
top-level step; call sites that do not attach traits get the conservative raw
|
||||
fetch.
|
||||
|
||||
**Row assembly** maps stale flags to the engine's `StaleNaN` and merges series
|
||||
with identical label sets (`sortAndMerge`) — the engine assumes storages never
|
||||
emit duplicates. Duplicate timestamps pass through as stored: uniqueness is
|
||||
ingest's job, and v1 feeds them to the engine as-is over the same data.
|
||||
|
||||
**The fingerprint filter is a shard-local semi-join.** The samples query
|
||||
restricts to the matched series by re-running the series predicates as an
|
||||
`IN (SELECT fingerprint FROM <local series table> ...)` subquery, not a GLOBAL
|
||||
broadcast of the matched set. ClickHouse materializes the subquery's set per
|
||||
shard before the scan, so it still engages the fingerprint primary-key column.
|
||||
Because the subquery re-executes the predicates after the lookup ran, it can
|
||||
match series registered in between; sample rows whose fingerprint the lookup
|
||||
never saw are skipped — the lookup is the read snapshot.
|
||||
|
||||
---
|
||||
|
||||
## Sharding
|
||||
|
||||
`samples_v4` and `time_series_v4` (and all their rollups) shard on the same key
|
||||
— `cityHash64(env, temporality, metric_name, fingerprint)` — so a series'
|
||||
samples and catalog rows live on the same shard. The semi-join above exploits
|
||||
that: each shard filters by its own series rows, which are exactly the series
|
||||
of that shard's samples.
|
||||
|
||||
The temporality filter on every samples statement
|
||||
(`temporality IN ['Cumulative', 'Unspecified']`) is a semantic no-op — the
|
||||
matched fingerprints already come from those temporalities — that engages the
|
||||
leading samples primary-key column.
|
||||
|
||||
Delta-temporality series stay invisible to PromQL exactly as they are in v1:
|
||||
the rollout gate is parity with v1, and a Delta stream fed to `rate()`
|
||||
as-if-cumulative would be wrong, not just new.
|
||||
|
||||
---
|
||||
|
||||
## Observability
|
||||
|
||||
Every statement carries a `log_comment` with
|
||||
`code.namespace=clickhouse-prometheus-v2` and `code.function.name` naming the
|
||||
call site, so this provider's work is attributable in `system.query_log`.
|
||||
@@ -30,11 +30,11 @@ yarn install:browsers # one-time Playwright browser install
|
||||
|
||||
### Starting the Test Environment
|
||||
|
||||
To spin up the backend stack (SigNoz, ClickHouse, Postgres, ClickHouse Keeper, Zeus mock, gateway mock, seeder, migrator-with-web) and keep it running:
|
||||
To spin up the backend stack (SigNoz, ClickHouse, Postgres, Zookeeper, Zeus mock, gateway mock, seeder, migrator-with-web) and keep it running:
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --with-web \
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse --with-web \
|
||||
e2e/bootstrap/setup.py::test_setup
|
||||
```
|
||||
|
||||
@@ -45,13 +45,8 @@ This command will:
|
||||
- Start the HTTP seeder container (`tests/seeder/` — exposing `/telemetry/{traces,logs,metrics}` POST + DELETE)
|
||||
- Write backend coordinates to `tests/e2e/.env.local` (loaded by `playwright.config.ts` via dotenv)
|
||||
- Keep containers running via the `--reuse` flag
|
||||
- Rebuild the SigNoz container from the current sources via the `--rebuild` flag
|
||||
|
||||
The `--with-web` flag builds the frontend into the SigNoz container — required for E2E. The build takes ~4 mins on a cold start; later builds are incremental.
|
||||
|
||||
### Rebuilding After Source Changes
|
||||
|
||||
The `--with-web` image bakes the built frontend in, so neither backend nor frontend changes are picked up while `--reuse` keeps the container running. `--rebuild` fixes that for both: it kills the SigNoz container, rebuilds the image incrementally (go build cache + pnpm store — a frontend-only change rebuilds in about a minute), and starts a fresh one while databases, mocks, migrations, and the seeder stay reused. The setup command above passes it, so the iteration loop is: change code → re-run the setup command → re-run your specs. `--rebuild` requires `--reuse` and cannot be combined with `--teardown` or `--clean`.
|
||||
The `--with-web` flag builds the frontend into the SigNoz container — required for E2E. The build takes ~4 mins on a cold start.
|
||||
|
||||
### Stopping the Test Environment
|
||||
|
||||
@@ -286,16 +281,13 @@ The full `playwright.config.ts` is the source of truth. Common things to tweak:
|
||||
The same pytest flags integration tests expose work here, since E2E reuses the shared fixture graph:
|
||||
|
||||
- `--reuse` — keep containers warm between runs (required for all iteration).
|
||||
- `--rebuild` — recreate the SigNoz container from the current sources (backend and, with `--with-web`, frontend) while the rest of the stack stays up. Requires `--reuse`.
|
||||
- `--teardown` — tear everything down.
|
||||
- `--clean` — prune the docker build caches, forcing the next image build to start cold.
|
||||
- `--with-web` — build the frontend into the SigNoz container. **Required for E2E**; integration tests don't need it.
|
||||
- `--sqlstore-provider`, `--postgres-version`, `--clickhouse-version`, etc. — see `docs/contributing/tests/integration.md`.
|
||||
- `--sqlstore-provider`, `--postgres-version`, `--clickhouse-version`, etc. — see `docs/contributing/integration.md`.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- **Always use the `--reuse` flag** when setting up the E2E stack. `--with-web` adds a ~4 min frontend build on a cold start; later builds are incremental.
|
||||
- **Changed backend or frontend code? Re-run the setup command** — it passes `--rebuild`, swapping the SigNoz container for one built from your current sources while the rest of the stack stays up.
|
||||
- **Always use the `--reuse` flag** when setting up the E2E stack. `--with-web` adds a ~4 min frontend build; you only want to pay that once.
|
||||
- **Don't teardown before setup.** `--reuse` correctly handles partially-set-up state, so chaining teardown → setup wastes time.
|
||||
- **Prefer UI-driven flows.** Playwright captures BE requests in the trace; a parallel `fetch` probe is almost always redundant. Drop to `page.request.*` only when the UI can't reach what you need.
|
||||
- **Use `page.waitForResponse` on UI clicks** to assert BE contracts — it still exercises the UI trigger path.
|
||||
|
||||
@@ -37,34 +37,13 @@ make py-test-setup
|
||||
Under the hood this runs, from `tests/`:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no integration/bootstrap/setup.py::test_setup
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/bootstrap/setup.py::test_setup
|
||||
```
|
||||
|
||||
This command will:
|
||||
- Start all required services (ClickHouse, PostgreSQL, ClickHouse Keeper, SigNoz, Zeus mock, gateway mock)
|
||||
- Start all required services (ClickHouse, PostgreSQL, Zookeeper, SigNoz, Zeus mock, gateway mock)
|
||||
- Register an admin user
|
||||
- Keep containers running via the `--reuse` flag
|
||||
- Rebuild the SigNoz container from the current sources via the `--rebuild` flag
|
||||
|
||||
### Rebuilding After Source Changes
|
||||
|
||||
`--reuse` keeps the running SigNoz container, which means backend source changes are not picked up. `--rebuild` fixes exactly that: it kills the existing SigNoz container, rebuilds the image (incremental — only changed packages recompile thanks to the build cache), and starts a fresh one, while everything else (databases, mocks, migrations) stays reused. `make py-test-setup` passes it by default, so the iteration loop is simply:
|
||||
|
||||
```bash
|
||||
make py-test-setup # (re)build signoz from your current sources
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/<suite>/
|
||||
# ... edit backend code or tests ...
|
||||
make py-test-setup # pick up the backend changes
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/<suite>/
|
||||
```
|
||||
|
||||
The same applies to the e2e stack. `--rebuild` requires `--reuse` and cannot be combined with `--teardown` or `--clean`.
|
||||
|
||||
Some suites define their own SigNoz variant in a suite-local `conftest.py` (`create_signoz(..., cache_key=...)` — e.g. `basepath`, `metricreduction`, `querier_json_body`). Those containers are not touched by `make py-test-setup`, which only rebuilds the default instance. For such suites, pass `--rebuild` on the suite run itself — it rebuilds every SigNoz variant the run instantiates:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild integration/tests/<suite>/
|
||||
```
|
||||
|
||||
### Stopping the Test Environment
|
||||
|
||||
@@ -77,21 +56,11 @@ make py-test-teardown
|
||||
Which runs:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no integration/bootstrap/setup.py::test_teardown
|
||||
uv run pytest --basetemp=./tmp/ -vv --teardown integration/bootstrap/setup.py::test_teardown
|
||||
```
|
||||
|
||||
This destroys the running integration test setup and cleans up resources.
|
||||
|
||||
### Cleaning the Image Build Cache
|
||||
|
||||
The `signoz:integration` image build keeps its Go build and module caches in BuildKit cache mounts, so rebuilds only recompile what changed. These caches survive `--teardown` (they belong to the Docker builder, not to any container). If a cache ever needs to be nuked — suspected corruption, disk pressure, or to force a genuinely cold build — pass the `--clean` flag:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --teardown --clean integration/bootstrap/setup.py::test_teardown
|
||||
```
|
||||
|
||||
`--clean` prunes the docker build artifacts backing the incremental image build at session start, so the next build starts from a clean slate. Images and regular layer cache stay intact, but note the pruning is host-wide — it clears build caches for other projects too, not just SigNoz's. The flag composes with any invocation — passing it on a normal `--reuse` run simply makes the next image build start cold (~3–4 minutes instead of seconds).
|
||||
|
||||
## Understanding the Integration Test Framework
|
||||
|
||||
Python and pytest form the foundation of the integration testing framework. Testcontainers are used to spin up disposable integration environments. WireMock is used to spin up **test doubles** of external services (Zeus cloud API, gateway, etc.).
|
||||
@@ -130,7 +99,7 @@ tests/
|
||||
│ ├── passwordauthn/
|
||||
│ ├── querier/
|
||||
│ └── ...
|
||||
└── e2e/ # Playwright suite (see docs/contributing/tests/e2e.md)
|
||||
└── e2e/ # Playwright suite (see docs/contributing/e2e.md)
|
||||
```
|
||||
|
||||
Each test suite follows these principles:
|
||||
@@ -255,9 +224,9 @@ Tests can be configured using pytest options:
|
||||
- `--sqlstore-provider` — Choose the SQL store provider (default: `postgres`)
|
||||
- `--sqlite-mode` — SQLite journal mode: `delete` or `wal` (default: `delete`). Only relevant when `--sqlstore-provider=sqlite`.
|
||||
- `--postgres-version` — PostgreSQL version (default: `15`)
|
||||
- `--clickhouse-version` — ClickHouse version, also used for ClickHouse Keeper (default: `25.12.5`)
|
||||
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.6`)
|
||||
- `--with-web` — Build the frontend into the SigNoz image (required for e2e)
|
||||
- `--clickhouse-version` — ClickHouse version (default: `25.5.6`)
|
||||
- `--zookeeper-version` — Zookeeper version (default: `3.7.1`)
|
||||
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.2`)
|
||||
|
||||
Example:
|
||||
|
||||
@@ -270,7 +239,6 @@ uv run pytest --basetemp=./tmp/ -vv --reuse \
|
||||
## What should I remember?
|
||||
|
||||
- **Always use the `--reuse` flag** when setting up the environment or running tests to keep containers warm. Without it every run rebuilds the stack (~4 mins).
|
||||
- **Changed backend code? Re-run `make py-test-setup`** — it passes `--rebuild`, swapping the SigNoz container for one built from your current sources while the rest of the stack stays up.
|
||||
- **Use the `--teardown` flag** only when cleaning up — mixing `--teardown` with `--reuse` is a contradiction.
|
||||
- **Do not pre-emptively teardown before setup.** If the stack is partially up, `--reuse` picks up from wherever it is. `make py-test-teardown` then `make py-test-setup` wastes minutes.
|
||||
- **Follow the naming convention** with two-digit numeric prefixes (`01_`, `02_`) for ordered test execution within a suite.
|
||||
@@ -279,5 +247,5 @@ uv run pytest --basetemp=./tmp/ -vv --reuse \
|
||||
- **Use descriptive test names** that clearly indicate what is being tested.
|
||||
- **Leverage fixtures** for common setup. The shared fixture package is at `tests/fixtures/` — reuse before adding new ones.
|
||||
- **Test both success and failure scenarios** (4xx / 5xx paths) to ensure robust functionality.
|
||||
- **Run `make py-fmt` and `make py-lint` before committing** Python changes — ruff format + ruff check.
|
||||
- **Run `make py-fmt` and `make py-lint` before committing** Python changes — black + isort + autoflake + pylint.
|
||||
- **`--sqlite-mode=wal` does not work on macOS.** The integration test environment runs SigNoz inside a Linux container with the SQLite database file mounted from the macOS host. WAL mode requires shared memory between connections, and connections crossing the VM boundary (macOS host ↔ Linux container) cannot share the WAL index, resulting in `SQLITE_IOERR_SHORT_READ`. WAL mode is tested in CI on Linux only.
|
||||
|
||||
@@ -8479,8 +8479,6 @@ export enum RuletypesCompareOperatorDTO {
|
||||
below = 'below',
|
||||
equal = 'equal',
|
||||
not_equal = 'not_equal',
|
||||
above_or_equal = 'above_or_equal',
|
||||
below_or_equal = 'below_or_equal',
|
||||
outside_bounds = 'outside_bounds',
|
||||
}
|
||||
export interface RuletypesBasicRuleThresholdDTO {
|
||||
|
||||
@@ -6,6 +6,10 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -124,15 +128,13 @@ export function useNavigateToExplorer(): (
|
||||
});
|
||||
}
|
||||
|
||||
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(preparedQuery));
|
||||
applySerializedParams(serialize(preparedQuery), urlParams);
|
||||
|
||||
const basePath =
|
||||
dataSource === DataSource.TRACES
|
||||
? ROUTES.TRACES_EXPLORER
|
||||
: ROUTES.LOGS_EXPLORER;
|
||||
const newExplorerPath = `${basePath}?${urlParams.toString()}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${JSONCompositeQuery}`;
|
||||
const newExplorerPath = `${basePath}?${urlParams.toString()}`;
|
||||
|
||||
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useQueryClient } from 'react-query';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
@@ -21,7 +20,6 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './CreateServiceAccountModal.styles.scss';
|
||||
import { Skeleton } from 'antd';
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
@@ -97,39 +95,33 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
testId="create-service-account-modal"
|
||||
>
|
||||
<div className="create-sa-modal__content">
|
||||
<AuthZGuardContent
|
||||
checks={[SACreatePermission]}
|
||||
fallbackOnLoading={<Skeleton active paragraph={{ rows: 1 }} />}
|
||||
<form
|
||||
id="create-sa-form"
|
||||
className="create-sa-form"
|
||||
onSubmit={handleSubmit(handleCreate)}
|
||||
>
|
||||
<form
|
||||
id="create-sa-form"
|
||||
className="create-sa-form"
|
||||
onSubmit={handleSubmit(handleCreate)}
|
||||
>
|
||||
<div className="create-sa-form__item">
|
||||
<label htmlFor="sa-name">Name</label>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: 'Name is required' }}
|
||||
render={({ field }): JSX.Element => (
|
||||
<Input
|
||||
id="sa-name"
|
||||
placeholder="Enter a name"
|
||||
className="create-sa-form__input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
data-testid="create-sa-name-input"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="create-sa-form__error">{errors.name.message}</p>
|
||||
<div className="create-sa-form__item">
|
||||
<label htmlFor="sa-name">Name</label>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: 'Name is required' }}
|
||||
render={({ field }): JSX.Element => (
|
||||
<Input
|
||||
id="sa-name"
|
||||
placeholder="Enter a name"
|
||||
className="create-sa-form__input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</AuthZGuardContent>
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="create-sa-form__error">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="create-sa-modal__footer">
|
||||
@@ -138,7 +130,6 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
data-testid="create-sa-cancel-btn"
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
@@ -146,14 +137,12 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
|
||||
<AuthZButton
|
||||
checks={[SACreatePermission]}
|
||||
withPortal={false}
|
||||
type="submit"
|
||||
form="create-sa-form"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
data-testid="create-sa-submit-btn"
|
||||
>
|
||||
Create Service Account
|
||||
</AuthZButton>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDenyAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CreateServiceAccountModal from '../CreateServiceAccountModal';
|
||||
|
||||
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactElement;
|
||||
}): React.ReactElement => children,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
@@ -40,7 +45,6 @@ describe('CreateServiceAccountModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
setupAuthzAdmin(),
|
||||
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
@@ -51,41 +55,23 @@ describe('CreateServiceAccountModal', () => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('submit button is disabled while the form is empty', async () => {
|
||||
it('submit button is disabled when form is empty', () => {
|
||||
renderModal();
|
||||
|
||||
// The form only renders once the create check resolves, and the name field
|
||||
// registers its `required` rule on mount, so the empty-form invalid state
|
||||
// settles a tick later.
|
||||
await screen.findByTestId('create-sa-name-input');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('submit button becomes disabled after clearing the name field', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByTestId('create-sa-name-input');
|
||||
const submitBtn = await screen.findByTestId('create-sa-submit-btn');
|
||||
|
||||
await user.type(nameInput, 'test');
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
|
||||
await user.clear(nameInput);
|
||||
await waitFor(() => expect(submitBtn).toBeDisabled());
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Service Account/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('successful submit shows toast.success and closes modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByTestId('create-sa-name-input');
|
||||
await user.type(nameInput, 'Deploy Bot');
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'Deploy Bot');
|
||||
|
||||
const submitBtn = screen.getByTestId('create-sa-submit-btn');
|
||||
const submitBtn = screen.getByRole('button', {
|
||||
name: /Create Service Account/i,
|
||||
});
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await user.click(submitBtn);
|
||||
|
||||
@@ -116,10 +102,11 @@ describe('CreateServiceAccountModal', () => {
|
||||
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByTestId('create-sa-name-input');
|
||||
await user.type(nameInput, 'Dupe Bot');
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'Dupe Bot');
|
||||
|
||||
const submitBtn = screen.getByTestId('create-sa-submit-btn');
|
||||
const submitBtn = screen.getByRole('button', {
|
||||
name: /Create Service Account/i,
|
||||
});
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await user.click(submitBtn);
|
||||
|
||||
@@ -144,45 +131,8 @@ describe('CreateServiceAccountModal', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const cancelBtn = await screen.findByTestId('create-sa-cancel-btn');
|
||||
await user.click(cancelBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('create-service-account-modal'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows inline permission denial and hides the form when create permission is denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
renderModal();
|
||||
|
||||
await expect(
|
||||
screen.findByText(/is not authorized to perform/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByTestId('create-sa-name-input')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('create-service-account-modal'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the footer usable when create permission is denied', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
renderModal();
|
||||
|
||||
await expect(
|
||||
screen.findByText(/is not authorized to perform/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// The footer lives outside the guard: submit is gated, Cancel still works.
|
||||
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByTestId('create-sa-cancel-btn'));
|
||||
await screen.findByTestId('create-service-account-modal');
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -195,7 +145,7 @@ describe('CreateServiceAccountModal', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByTestId('create-sa-name-input');
|
||||
const nameInput = screen.getByPlaceholderText('Enter a name');
|
||||
await user.type(nameInput, 'Bot');
|
||||
await user.clear(nameInput);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
buildSAAttachPermission,
|
||||
@@ -37,104 +36,91 @@ function KeyFormPhase({
|
||||
onClose,
|
||||
accountId,
|
||||
}: KeyFormPhaseProps): JSX.Element {
|
||||
const checks = accountId
|
||||
? [APIKeyCreatePermission, buildSAAttachPermission(accountId)]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<form id={FORM_ID} className="add-key-modal__form" onSubmit={onSubmit}>
|
||||
<AuthZGuardContent checks={checks}>
|
||||
<>
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label" htmlFor="key-name">
|
||||
Name <span style={{ color: 'var(--destructive)' }}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="key-name"
|
||||
placeholder="Enter key name e.g.: Service Owner"
|
||||
className="add-key-modal__input"
|
||||
testId="add-key-name-input"
|
||||
{...register('keyName', {
|
||||
required: true,
|
||||
validate: (v) => !!v.trim(),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label" htmlFor="key-name">
|
||||
Name <span style={{ color: 'var(--destructive)' }}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="key-name"
|
||||
placeholder="Enter key name e.g.: Service Owner"
|
||||
className="add-key-modal__input"
|
||||
{...register('keyName', {
|
||||
required: true,
|
||||
validate: (v) => !!v.trim(),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__field">
|
||||
<span className="add-key-modal__label">Expiration</span>
|
||||
<div className="add-key-modal__field">
|
||||
<span className="add-key-modal__label">Expiration</span>
|
||||
<Controller
|
||||
name="expiryMode"
|
||||
control={control}
|
||||
render={({ field }): JSX.Element => (
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={field.value}
|
||||
onChange={(val: string): void => {
|
||||
if (val) {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
className="add-key-modal__expiry-toggle"
|
||||
items={[
|
||||
{ value: ExpiryMode.NONE, label: 'No Expiration' },
|
||||
{ value: ExpiryMode.DATE, label: 'Set Expiration Date' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expiryMode === ExpiryMode.DATE && (
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label" htmlFor="expiry-date">
|
||||
Expiration Date
|
||||
</label>
|
||||
<div className="add-key-modal__datepicker">
|
||||
<Controller
|
||||
name="expiryMode"
|
||||
name="expiryDate"
|
||||
control={control}
|
||||
render={({ field }): JSX.Element => (
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
<DatePicker
|
||||
id="expiry-date"
|
||||
value={field.value}
|
||||
onChange={(val: string): void => {
|
||||
if (val) {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
className="add-key-modal__expiry-toggle"
|
||||
items={[
|
||||
{ value: ExpiryMode.NONE, label: 'No Expiration' },
|
||||
{ value: ExpiryMode.DATE, label: 'Set Expiration Date' },
|
||||
]}
|
||||
onChange={field.onChange}
|
||||
popupClassName="add-key-modal-datepicker-popup"
|
||||
getPopupContainer={popupContainer}
|
||||
disabledDate={disabledDate}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expiryMode === ExpiryMode.DATE && (
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label" htmlFor="expiry-date">
|
||||
Expiration Date
|
||||
</label>
|
||||
<div className="add-key-modal__datepicker">
|
||||
<Controller
|
||||
name="expiryDate"
|
||||
control={control}
|
||||
render={({ field }): JSX.Element => (
|
||||
<DatePicker
|
||||
id="expiry-date"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
popupClassName="add-key-modal-datepicker-popup"
|
||||
getPopupContainer={popupContainer}
|
||||
disabledDate={disabledDate}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</AuthZGuardContent>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="add-key-modal__footer">
|
||||
<div className="add-key-modal__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="add-key-cancel-btn"
|
||||
>
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<AuthZButton
|
||||
checks={checks}
|
||||
checks={[
|
||||
APIKeyCreatePermission,
|
||||
buildSAAttachPermission(accountId ?? ''),
|
||||
]}
|
||||
authZEnabled={!!accountId}
|
||||
withPortal={false}
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
testId="add-key-submit-btn"
|
||||
>
|
||||
Create Key
|
||||
</AuthZButton>
|
||||
|
||||
@@ -92,7 +92,6 @@ function DeleteAccountModal(): JSX.Element {
|
||||
loading={isDeleting}
|
||||
onClick={handleConfirm}
|
||||
data-testid="confirm-delete-btn"
|
||||
withPortal={false}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
|
||||
@@ -60,7 +60,6 @@ function EditKeyForm({
|
||||
<AuthZTooltip
|
||||
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
|
||||
enabled={!!keyItem?.id}
|
||||
withPortal={false}
|
||||
>
|
||||
<div className="edit-key-modal__key-display">
|
||||
<span className="edit-key-modal__id-text">{keyItem?.name || '—'}</span>
|
||||
@@ -169,7 +168,6 @@ function EditKeyForm({
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={onRevokeClick}
|
||||
withPortal={false}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
@@ -188,7 +186,6 @@ function EditKeyForm({
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
withPortal={false}
|
||||
>
|
||||
Save Changes
|
||||
</AuthZButton>
|
||||
|
||||
@@ -114,14 +114,13 @@ function buildColumns({
|
||||
render: (_, record): JSX.Element => {
|
||||
const tooltipTitle = isDisabled ? 'Service account disabled' : 'Revoke Key';
|
||||
return (
|
||||
<Tooltip title={tooltipTitle} placement="bottom">
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<AuthZButton
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(record.id),
|
||||
buildSADetachPermission(accountId),
|
||||
]}
|
||||
authZEnabled={!isDisabled && !!accountId}
|
||||
withPortal={false}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="destructive"
|
||||
@@ -215,7 +214,6 @@ function KeysTab({
|
||||
<AuthZButton
|
||||
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
|
||||
authZEnabled={!isDisabled && !!accountId}
|
||||
withPortal={false}
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
|
||||
@@ -90,20 +90,14 @@ function OverviewTab({
|
||||
Name
|
||||
</label>
|
||||
{isDisabled ? (
|
||||
<AuthZTooltip
|
||||
checks={[buildSAUpdatePermission(account.id)]}
|
||||
withPortal={false}
|
||||
>
|
||||
<AuthZTooltip checks={[buildSAUpdatePermission(account.id)]}>
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{localName || '—'}</span>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
</AuthZTooltip>
|
||||
) : (
|
||||
<AuthZTooltip
|
||||
checks={[buildSAUpdatePermission(account.id)]}
|
||||
withPortal={false}
|
||||
>
|
||||
<AuthZTooltip checks={[buildSAUpdatePermission(account.id)]}>
|
||||
<Input
|
||||
id="sa-name"
|
||||
value={localName}
|
||||
|
||||
@@ -55,7 +55,6 @@ export function RevokeKeyFooter({
|
||||
color="destructive"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
withPortal={false}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
APIKeyCreatePermission,
|
||||
buildSAAttachPermission,
|
||||
buildSADeletePermission,
|
||||
buildSAReadPermission,
|
||||
buildSAUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import {
|
||||
@@ -377,7 +376,6 @@ function ServiceAccountDrawer({
|
||||
<AuthZButton
|
||||
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
|
||||
authZEnabled={!!selectedAccountId}
|
||||
withPortal={false}
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={(): void => {
|
||||
@@ -393,12 +391,8 @@ function ServiceAccountDrawer({
|
||||
Cancel
|
||||
</Button>
|
||||
<AuthZButton
|
||||
checks={[
|
||||
buildSAReadPermission(selectedAccountId ?? ''),
|
||||
buildSAUpdatePermission(selectedAccountId ?? ''),
|
||||
]}
|
||||
checks={[buildSAUpdatePermission(selectedAccountId ?? '')]}
|
||||
authZEnabled={!!selectedAccountId}
|
||||
withPortal={false}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
@@ -471,7 +465,6 @@ function ServiceAccountDrawer({
|
||||
buildSAAttachPermission(selectedAccountId ?? ''),
|
||||
]}
|
||||
authZEnabled={!isDeleted && !!selectedAccountId}
|
||||
withPortal={false}
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { buildSAAttachPermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
setupAuthzDenyAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
@@ -71,29 +66,28 @@ describe('AddKeyModal', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
// The form only renders once the checks resolve, so waiting for it also
|
||||
// guarantees the button is no longer in its authz-loading state.
|
||||
const nameInput = await screen.findByTestId('add-key-name-input');
|
||||
const createBtn = await screen.findByTestId('add-key-submit-btn');
|
||||
expect(screen.getByRole('button', { name: /Create Key/i })).toBeDisabled();
|
||||
|
||||
expect(createBtn).toBeDisabled();
|
||||
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'My Key');
|
||||
|
||||
await user.type(nameInput, 'My Key');
|
||||
await waitFor(() => expect(createBtn).not.toBeDisabled());
|
||||
|
||||
await user.clear(nameInput);
|
||||
await waitFor(() => expect(createBtn).toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Key/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('successful creation transitions to phase 2 with key displayed and security callout', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByTestId('add-key-name-input');
|
||||
const submitBtn = await screen.findByTestId('add-key-submit-btn');
|
||||
await user.type(nameInput, 'Deploy Key');
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await user.click(submitBtn);
|
||||
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Key/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Create Key/i }));
|
||||
|
||||
await screen.findByText('snz_abc123xyz456secret');
|
||||
expect(screen.getByText(/Store the key securely/i)).toBeInTheDocument();
|
||||
@@ -105,11 +99,13 @@ describe('AddKeyModal', () => {
|
||||
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByTestId('add-key-name-input');
|
||||
const submitBtn = await screen.findByTestId('add-key-submit-btn');
|
||||
await user.type(nameInput, 'Deploy Key');
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await user.click(submitBtn);
|
||||
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Key/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Create Key/i }));
|
||||
|
||||
await screen.findByText('snz_abc123xyz456secret');
|
||||
|
||||
@@ -127,57 +123,12 @@ describe('AddKeyModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows inline permission denial and hides the form when key create is denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
renderModal();
|
||||
|
||||
await expect(
|
||||
screen.findByText(/is not authorized to perform/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByTestId('add-key-name-input')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('add-key-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the footer usable when key create is denied', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
renderModal();
|
||||
|
||||
await expect(
|
||||
screen.findByText(/is not authorized to perform/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// The footer lives outside the guard: submit is gated, Cancel still works.
|
||||
expect(screen.getByTestId('add-key-submit-btn')).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByTestId('add-key-cancel-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('add-key-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows inline permission denial when attach on the service account is denied', async () => {
|
||||
server.use(setupAuthzDeny(buildSAAttachPermission('sa-1')));
|
||||
|
||||
renderModal();
|
||||
|
||||
await expect(
|
||||
screen.findByText(/is not authorized to perform/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByTestId('add-key-name-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Cancel button closes the modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const cancelBtn = await screen.findByTestId('add-key-cancel-btn');
|
||||
await user.click(cancelBtn);
|
||||
await screen.findByTestId('add-key-modal');
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('add-key-modal')).not.toBeInTheDocument();
|
||||
|
||||
@@ -18,7 +18,6 @@ export enum QueryParams {
|
||||
q = 'q',
|
||||
activeLogId = 'activeLogId',
|
||||
timeRange = 'timeRange',
|
||||
compositeQuery = 'compositeQuery',
|
||||
panelTypes = 'panelTypes',
|
||||
pageSize = 'pageSize',
|
||||
viewMode = 'viewMode',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getAutoContexts } from '../getAutoContexts';
|
||||
|
||||
@@ -51,8 +53,8 @@ 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 serializedParams = serialize(query as unknown as Query);
|
||||
const search = `?${QueryParams.ruleId}=${ruleId}&${serializedParams.toString()}`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.EDIT_ALERTS, search);
|
||||
|
||||
@@ -72,8 +74,8 @@ describe('getAutoContexts', () => {
|
||||
|
||||
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 serializedParams = serialize(query as unknown as Query);
|
||||
const search = `?${serializedParams.toString()}`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.ALERTS_NEW, search);
|
||||
|
||||
@@ -239,4 +241,24 @@ describe('getAutoContexts', () => {
|
||||
),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('decodes the serialized composite query into metadata.query', () => {
|
||||
const query = { builder: { queryData: [] } } as unknown as Query;
|
||||
const search = `?${serialize(query).toString()}`;
|
||||
|
||||
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
|
||||
|
||||
expect(context.metadata?.query).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('omits metadata.query when no serialized query is in the URL', () => {
|
||||
// Detection no longer gates on the `compositeQuery` key — it routes
|
||||
// through `deserialize`/the adapter list — so non-query params (time
|
||||
// range, etc.) must not be mistaken for a query.
|
||||
const search = `?${QueryParams.startTime}=1700000000000&${QueryParams.endTime}=1700003600000`;
|
||||
|
||||
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
|
||||
|
||||
expect(context.metadata).not.toHaveProperty('query');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
undoExecution,
|
||||
} from 'api/ai-assistant/chat';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import {
|
||||
ArchiveRestore,
|
||||
@@ -363,8 +363,8 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] off-page → history.push', base);
|
||||
const encoded = encodeURIComponent(JSON.stringify(normalized));
|
||||
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
|
||||
const params = serialize(normalized);
|
||||
deps.history.push(`${base}?${params.toString()}`);
|
||||
}
|
||||
|
||||
/** Picks the right rollback API call for a given action kind. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getViewById } from 'api/saveView/getViewById';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
import { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -218,7 +219,9 @@ describe('buildExplorerNavigationUrl', () => {
|
||||
);
|
||||
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain(`${QueryParams.compositeQuery}=`);
|
||||
|
||||
const params = new URLSearchParams(new URL(url, 'http://x').search);
|
||||
expect(deserialize(params)).not.toBeNull();
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@ import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
@@ -75,10 +79,7 @@ export function buildExplorerNavigationUrl(
|
||||
searchParams: Record<string, unknown>,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
applySerializedParams(serialize(query), params);
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
params.set(key, JSON.stringify(value));
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MessageContext } from 'api/ai-assistant/chat';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { AlertListTabs } from 'pages/AlertList/types';
|
||||
import { NEW_PANEL_ID } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
@@ -345,15 +346,9 @@ function collectSharedMetadata(
|
||||
out.timeRange = { start: startTime, end: endTime };
|
||||
}
|
||||
|
||||
// Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`.
|
||||
const compositeQueryRaw = params.get(QueryParams.compositeQuery);
|
||||
if (compositeQueryRaw) {
|
||||
try {
|
||||
out.query = JSON.parse(decodeURIComponent(compositeQueryRaw));
|
||||
} catch {
|
||||
// Malformed JSON in the URL — drop silently rather than throw
|
||||
// inside a context-collection helper.
|
||||
}
|
||||
const decodedQuery = deserialize(params);
|
||||
if (decodedQuery) {
|
||||
out.query = decodedQuery;
|
||||
}
|
||||
|
||||
// Saved view selectors (logs / traces explorer) and dashboard variables.
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from 'hooks/useResourceAttribute/utils';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { deserialize, serializeToParams } from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import history from 'lib/history';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
@@ -61,7 +62,6 @@ type QueryParams = {
|
||||
pageSize: number;
|
||||
exceptionType?: string;
|
||||
serviceName?: string;
|
||||
compositeQuery?: string;
|
||||
};
|
||||
|
||||
function AllErrors(): JSX.Element {
|
||||
@@ -78,7 +78,6 @@ function AllErrors(): JSX.Element {
|
||||
getUpdatedPageSize,
|
||||
getUpdatedExceptionType,
|
||||
getUpdatedServiceName,
|
||||
getUpdatedCompositeQuery,
|
||||
} = useMemo(
|
||||
() => ({
|
||||
updatedOrder: getOrder(params.get(urlKey.order)),
|
||||
@@ -87,7 +86,6 @@ function AllErrors(): JSX.Element {
|
||||
getUpdatedPageSize: getUpdatePageSize(params.get(urlKey.pageSize)),
|
||||
getUpdatedExceptionType: getFilterString(params.get(urlKey.exceptionType)),
|
||||
getUpdatedServiceName: getFilterString(params.get(urlKey.serviceName)),
|
||||
getUpdatedCompositeQuery: getFilterString(params.get(urlKey.compositeQuery)),
|
||||
}),
|
||||
[params],
|
||||
);
|
||||
@@ -213,7 +211,6 @@ function AllErrors(): JSX.Element {
|
||||
offset: getUpdatedOffset,
|
||||
orderParam: getUpdatedParams,
|
||||
pageSize: getUpdatedPageSize,
|
||||
compositeQuery: getUpdatedCompositeQuery,
|
||||
};
|
||||
|
||||
if (exceptionFilterValue && exceptionFilterValue !== 'undefined') {
|
||||
@@ -224,7 +221,13 @@ function AllErrors(): JSX.Element {
|
||||
queryParams.serviceName = serviceFilterValue;
|
||||
}
|
||||
|
||||
history.replace(`${pathname}?${createQueryParams(queryParams)}`);
|
||||
// Carry the active query across the filter change so the trace context survives.
|
||||
history.replace(
|
||||
`${pathname}?${createQueryParams({
|
||||
...queryParams,
|
||||
...(compositeData ? serializeToParams(compositeData) : {}),
|
||||
})}`,
|
||||
);
|
||||
confirm();
|
||||
},
|
||||
[
|
||||
@@ -233,7 +236,7 @@ function AllErrors(): JSX.Element {
|
||||
getUpdatedPageSize,
|
||||
getUpdatedParams,
|
||||
getUpdatedServiceName,
|
||||
getUpdatedCompositeQuery,
|
||||
compositeData,
|
||||
pathname,
|
||||
updatedOrder,
|
||||
],
|
||||
@@ -438,7 +441,9 @@ function AllErrors(): JSX.Element {
|
||||
serviceName: getFilterString(params.get(urlKey.serviceName)),
|
||||
exceptionType: getFilterString(params.get(urlKey.exceptionType)),
|
||||
});
|
||||
const compositeQuery = params.get(urlKey.compositeQuery) || '';
|
||||
// Re-serialize from the live URL rather than forwarding the raw param, so
|
||||
// every key the adapter owns is carried over.
|
||||
const compositeQuery = deserialize(params);
|
||||
history.replace(
|
||||
`${pathname}?${createQueryParams({
|
||||
order: updatedOrder,
|
||||
@@ -447,7 +452,7 @@ function AllErrors(): JSX.Element {
|
||||
pageSize,
|
||||
exceptionType,
|
||||
serviceName,
|
||||
compositeQuery,
|
||||
...(compositeQuery ? serializeToParams(compositeQuery) : {}),
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,32 +46,11 @@ export const MOCK_USE_QUERIES_DATA = [
|
||||
},
|
||||
];
|
||||
|
||||
// Deliberately double-encoded: this is the legacy shape older bookmarks/shared
|
||||
// links still carry, so it also covers the serializer's legacy decode fallback.
|
||||
export const INIT_URL_WITH_COMMON_QUERY =
|
||||
'/exceptions?compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522dataSource%2522%253A%2522traces%2522%252C%2522queryName%2522%253A%2522A%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522id%2522%253A%2522----resource--false%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522key%2522%253A%2522%2522%252C%2522isColumn%2522%253Afalse%252C%2522type%2522%253A%2522resource%2522%252C%2522isJSON%2522%253Afalse%257D%252C%2522timeAggregation%2522%253A%2522rate%2522%252C%2522spaceAggregation%2522%253A%2522sum%2522%252C%2522functions%2522%253A%255B%255D%252C%2522filters%2522%253A%257B%2522items%2522%253A%255B%257B%2522id%2522%253A%2522db118ac7-9313-4adb-963f-f31b5b32c496%2522%252C%2522op%2522%253A%2522in%2522%252C%2522key%2522%253A%257B%2522key%2522%253A%2522deployment.environment%2522%252C%2522dataType%2522%253A%2522string%2522%252C%2522type%2522%253A%2522resource%2522%252C%2522isColumn%2522%253Afalse%252C%2522isJSON%2522%253Afalse%257D%252C%2522value%2522%253A%2522mq-kafka%2522%257D%255D%252C%2522op%2522%253A%2522AND%2522%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522stepInterval%2522%253A60%252C%2522having%2522%253A%255B%255D%252C%2522limit%2522%253Anull%252C%2522orderBy%2522%253A%255B%255D%252C%2522groupBy%2522%253A%255B%255D%252C%2522legend%2522%253A%2522%2522%252C%2522reduceTo%2522%253A%2522avg%2522%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%257D%252C%2522promql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522query%2522%253A%2522%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%257D%255D%252C%2522clickhouse_sql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%252C%2522query%2522%253A%2522%2522%257D%255D%252C%2522id%2522%253A%2522dd576d04-0822-476d-b0c2-807a7af2e5e7%2522%257D';
|
||||
|
||||
export const extractCompositeQueryObject = (
|
||||
url: string,
|
||||
): Record<string, unknown> | null => {
|
||||
try {
|
||||
const urlObj = new URL(`http://dummy-base${url}`); // Add dummy base to parse relative URL
|
||||
const encodedParam = urlObj.searchParams.get('compositeQuery');
|
||||
|
||||
if (!encodedParam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode twice
|
||||
const firstDecode = decodeURIComponent(encodedParam);
|
||||
const secondDecode = decodeURIComponent(firstDecode);
|
||||
|
||||
// Parse JSON
|
||||
return JSON.parse(secondDecode);
|
||||
} catch (err) {
|
||||
console.error('Failed to extract compositeQuery:', err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const TAG_FROM_QUERY = [
|
||||
{
|
||||
BoolValues: [],
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
export const isOrder = (order: string | null): order is Order =>
|
||||
!!(order === 'ascending' || order === 'descending');
|
||||
|
||||
// The serialized query is deliberately absent: its param keys are owned by the
|
||||
// compositeQuery serializer (see lib/compositeQuery), not spelled out here.
|
||||
export const urlKey = {
|
||||
order: 'order',
|
||||
offset: 'offset',
|
||||
@@ -18,7 +20,6 @@ export const urlKey = {
|
||||
pageSize: 'pageSize',
|
||||
exceptionType: 'exceptionType',
|
||||
serviceName: 'serviceName',
|
||||
compositeQuery: 'compositeQuery',
|
||||
};
|
||||
|
||||
export const isOrderParams = (orderBy: string | null): orderBy is OrderBy =>
|
||||
|
||||
@@ -66,10 +66,6 @@ function ThresholdItem({
|
||||
return '=';
|
||||
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
|
||||
return '!=';
|
||||
case AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO:
|
||||
return '>=';
|
||||
case AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO:
|
||||
return '<=';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -83,10 +83,6 @@ const getOperatorWord = (op: AlertThresholdOperator): string => {
|
||||
return 'equal';
|
||||
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
|
||||
return 'not equal';
|
||||
case AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO:
|
||||
return 'equal or exceed';
|
||||
case AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO:
|
||||
return 'equal or fall below';
|
||||
default:
|
||||
return 'exceed';
|
||||
}
|
||||
@@ -102,10 +98,6 @@ const getThresholdValue = (op: AlertThresholdOperator): number => {
|
||||
return 100;
|
||||
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
|
||||
return 0;
|
||||
case AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO:
|
||||
return 80;
|
||||
case AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO:
|
||||
return 50;
|
||||
default:
|
||||
return 80;
|
||||
}
|
||||
@@ -124,8 +116,6 @@ const getDataPoints = (
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 100, 105, 90, 100],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 0, 10, 15, 0],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
|
||||
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [75, 80, 90, 78, 95],
|
||||
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [60, 50, 40, 55, 35],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
|
||||
},
|
||||
[AlertThresholdMatchType.ALL_THE_TIME]: {
|
||||
@@ -133,8 +123,6 @@ const getDataPoints = (
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [100, 100, 100, 100, 100],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [85, 87, 90, 88, 95],
|
||||
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [80, 87, 90, 88, 95],
|
||||
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [50, 40, 35, 42, 38],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [85, 87, 90, 88, 95],
|
||||
},
|
||||
[AlertThresholdMatchType.ON_AVERAGE]: {
|
||||
@@ -142,8 +130,6 @@ const getDataPoints = (
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 105, 100, 95, 105],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
|
||||
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [70, 85, 90, 75, 80],
|
||||
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [60, 40, 55, 45, 50],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
|
||||
},
|
||||
[AlertThresholdMatchType.IN_TOTAL]: {
|
||||
@@ -151,8 +137,6 @@ const getDataPoints = (
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [20, 20, 20, 20, 20],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [10, 15, 25, 5, 30],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [10, 15, 25, 5, 30],
|
||||
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [10, 15, 25, 5, 25],
|
||||
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [8, 5, 10, 12, 15],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [10, 15, 25, 5, 30],
|
||||
},
|
||||
[AlertThresholdMatchType.LAST]: {
|
||||
@@ -160,8 +144,6 @@ const getDataPoints = (
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: [75, 85, 90, 78, 100],
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [75, 85, 90, 78, 25],
|
||||
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
|
||||
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: [75, 85, 90, 78, 80],
|
||||
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: [75, 85, 90, 78, 50],
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
|
||||
},
|
||||
};
|
||||
@@ -175,8 +157,6 @@ const getTooltipOperatorSymbol = (op: AlertThresholdOperator): string => {
|
||||
[AlertThresholdOperator.IS_BELOW]: '<',
|
||||
[AlertThresholdOperator.IS_EQUAL_TO]: '=',
|
||||
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: '!=',
|
||||
[AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO]: '>=',
|
||||
[AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO]: '<=',
|
||||
[AlertThresholdOperator.ABOVE_BELOW]: '>',
|
||||
};
|
||||
return symbolMap[op] || '>';
|
||||
@@ -272,10 +252,6 @@ export const getMatchTypeTooltip = (
|
||||
return p === thresholdValue;
|
||||
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
|
||||
return p !== thresholdValue;
|
||||
case AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO:
|
||||
return p >= thresholdValue;
|
||||
case AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO:
|
||||
return p <= thresholdValue;
|
||||
default:
|
||||
return p > thresholdValue;
|
||||
}
|
||||
@@ -318,8 +294,7 @@ export const getMatchTypeTooltip = (
|
||||
matchType={matchType}
|
||||
>
|
||||
Alert triggers (all points {operatorWord} {thresholdValue})<br />
|
||||
If any point didn't {operatorWord} {thresholdValue}, no alert would
|
||||
fire
|
||||
If any point was {thresholdValue}, no alert would fire
|
||||
</TooltipExample>
|
||||
<TooltipLink />
|
||||
</TooltipContent>
|
||||
|
||||
@@ -532,7 +532,7 @@ describe('Footer utils', () => {
|
||||
['symbol', '>', 'at_least_once'],
|
||||
['literal', 'above', 'at_least_once'],
|
||||
['short', 'eq', 'avg'],
|
||||
['inclusive', 'above_or_equal', 'at_least_once'],
|
||||
['UI-unexposed', 'above_or_equal', 'at_least_once'],
|
||||
])(
|
||||
'round-trips %s op/matchType unchanged through the submit payload (%s / %s)',
|
||||
(_desc, op, matchType) => {
|
||||
|
||||
@@ -332,20 +332,25 @@ describe('CreateAlertV2 utils', () => {
|
||||
['not_equal', AlertThresholdOperator.IS_NOT_EQUAL_TO],
|
||||
['not_eq', AlertThresholdOperator.IS_NOT_EQUAL_TO],
|
||||
['!=', AlertThresholdOperator.IS_NOT_EQUAL_TO],
|
||||
['5', AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO],
|
||||
['above_or_equal', AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO],
|
||||
['above_or_eq', AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO],
|
||||
['>=', AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO],
|
||||
['6', AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO],
|
||||
['below_or_equal', AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO],
|
||||
['below_or_eq', AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO],
|
||||
['<=', AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO],
|
||||
['7', AlertThresholdOperator.ABOVE_BELOW],
|
||||
['outside_bounds', AlertThresholdOperator.ABOVE_BELOW],
|
||||
])('maps backend alias %s to canonical enum', (alias, expected) => {
|
||||
expect(normalizeOperator(alias)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['5', 'above_or_equal'],
|
||||
['above_or_equal', 'above_or_equal'],
|
||||
['above_or_eq', 'above_or_equal'],
|
||||
['>=', 'above_or_equal'],
|
||||
['6', 'below_or_equal'],
|
||||
['below_or_equal', 'below_or_equal'],
|
||||
['below_or_eq', 'below_or_equal'],
|
||||
['<=', 'below_or_equal'],
|
||||
])('returns undefined for UI-unexposed alias %s (%s family)', (alias) => {
|
||||
expect(normalizeOperator(alias)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for unknown values', () => {
|
||||
expect(normalizeOperator('gibberish')).toBeUndefined();
|
||||
expect(normalizeOperator(undefined)).toBeUndefined();
|
||||
@@ -408,8 +413,8 @@ describe('CreateAlertV2 utils', () => {
|
||||
['symbol', '>', 'at_least_once'],
|
||||
['short form', 'eq', 'avg'],
|
||||
['mixed numeric and literal', '7', 'last'],
|
||||
['inclusive literal operator', 'above_or_equal', 'at_least_once'],
|
||||
['inclusive numeric operator', '5', 'at_least_once'],
|
||||
['UI-unexposed operator', 'above_or_equal', 'at_least_once'],
|
||||
['UI-unexposed numeric operator', '5', 'at_least_once'],
|
||||
])('preserves %s op/matchType verbatim (%s / %s)', (_desc, op, matchType) => {
|
||||
const state = getThresholdStateFromAlertDef(buildDef(op, matchType));
|
||||
expect(state.operator).toBe(op);
|
||||
|
||||
@@ -2,8 +2,9 @@ import { AlertThresholdMatchType, AlertThresholdOperator } from './types';
|
||||
|
||||
// Mirrors the backend's CompareOperator.Normalize() in
|
||||
// pkg/types/ruletypes/compare.go. Maps any accepted alias to the enum value
|
||||
// the dropdown understands. Returns undefined for unknown values so callers
|
||||
// can keep the raw value on screen instead of silently rewriting it.
|
||||
// the dropdown understands. Returns undefined for aliases the UI does not
|
||||
// expose (e.g. above_or_equal, below_or_equal) so callers can keep the raw
|
||||
// value on screen instead of silently rewriting it.
|
||||
export function normalizeOperator(
|
||||
raw: string | undefined,
|
||||
): AlertThresholdOperator | undefined {
|
||||
@@ -26,16 +27,6 @@ export function normalizeOperator(
|
||||
case 'not_eq':
|
||||
case '!=':
|
||||
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
|
||||
case '5':
|
||||
case 'above_or_equal':
|
||||
case 'above_or_eq':
|
||||
case '>=':
|
||||
return AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO;
|
||||
case '6':
|
||||
case 'below_or_equal':
|
||||
case 'below_or_eq':
|
||||
case '<=':
|
||||
return AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO;
|
||||
case '7':
|
||||
case 'outside_bounds':
|
||||
return AlertThresholdOperator.ABOVE_BELOW;
|
||||
|
||||
@@ -125,14 +125,6 @@ export const THRESHOLD_OPERATOR_OPTIONS = [
|
||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'BELOW' },
|
||||
{ value: AlertThresholdOperator.IS_EQUAL_TO, label: 'EQUAL TO' },
|
||||
{ value: AlertThresholdOperator.IS_NOT_EQUAL_TO, label: 'NOT EQUAL TO' },
|
||||
{
|
||||
value: AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO,
|
||||
label: 'ABOVE OR EQUAL TO',
|
||||
},
|
||||
{
|
||||
value: AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO,
|
||||
label: 'BELOW OR EQUAL TO',
|
||||
},
|
||||
];
|
||||
|
||||
export const ANOMALY_THRESHOLD_OPERATOR_OPTIONS = [
|
||||
|
||||
@@ -99,8 +99,6 @@ export enum AlertThresholdOperator {
|
||||
IS_BELOW = 'below',
|
||||
IS_EQUAL_TO = 'equal',
|
||||
IS_NOT_EQUAL_TO = 'not_equal',
|
||||
IS_ABOVE_OR_EQUAL_TO = 'above_or_equal',
|
||||
IS_BELOW_OR_EQUAL_TO = 'below_or_equal',
|
||||
ABOVE_BELOW = 'outside_bounds',
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { memo } from 'react';
|
||||
import { Card, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES, PANEL_TYPES_INITIAL_QUERY } from 'constants/queryBuilder';
|
||||
import { serializeToParams } from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import history from 'lib/history';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
@@ -28,9 +28,7 @@ function PanelTypeSelectionModal(): JSX.Element {
|
||||
const queryParams = {
|
||||
graphType: name,
|
||||
widgetId: id,
|
||||
[QueryParams.compositeQuery]: JSON.stringify(
|
||||
PANEL_TYPES_INITIAL_QUERY[name],
|
||||
),
|
||||
...serializeToParams(PANEL_TYPES_INITIAL_QUERY[name]),
|
||||
};
|
||||
|
||||
history.push(
|
||||
|
||||
@@ -63,6 +63,8 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useErrorNotification from 'hooks/useErrorNotification';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { serializeToParams } from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
import { cloneDeep, isEqual, omit } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -174,7 +176,7 @@ function ExplorerOptions({
|
||||
|
||||
const handleConditionalQueryModification = useCallback(
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
(defaultQuery: Query | null): string => {
|
||||
(defaultQuery: Query | null): Record<string, string> => {
|
||||
const queryToUse = defaultQuery || query;
|
||||
if (!queryToUse) {
|
||||
throw new Error('No query provided');
|
||||
@@ -184,7 +186,7 @@ function ExplorerOptions({
|
||||
StringOperators.NOOP &&
|
||||
sourcepage !== DataSource.LOGS
|
||||
) {
|
||||
return JSON.stringify(queryToUse);
|
||||
return serializeToParams(queryToUse);
|
||||
}
|
||||
|
||||
// Convert NOOP to COUNT for alerts and strip orderBy for logs
|
||||
@@ -208,14 +210,7 @@ function ExplorerOptions({
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(modifiedQuery);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
'Failed to stringify modified query: ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
return serializeToParams(modifiedQuery);
|
||||
},
|
||||
[panelType, query, sourcepage],
|
||||
);
|
||||
@@ -238,13 +233,9 @@ function ExplorerOptions({
|
||||
});
|
||||
}
|
||||
|
||||
const stringifiedQuery = handleConditionalQueryModification(defaultQuery);
|
||||
const serializedParams = handleConditionalQueryModification(defaultQuery);
|
||||
|
||||
history.push(
|
||||
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
|
||||
stringifiedQuery,
|
||||
)}`,
|
||||
);
|
||||
history.push(`${ROUTES.ALERTS_NEW}?${createQueryParams(serializedParams)}`);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[handleConditionalQueryModification, history],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MOCK_QUERY } from 'container/QueryTable/Drilldown/__tests__/mockTableData';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
defaultFeatureFlags,
|
||||
@@ -380,9 +381,9 @@ describe('ExplorerOptionWrapper', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/dashboard/${TEST_DASHBOARD_ID}/new?graphType=${panelTypeParam}&widgetId=${widgetId}&compositeQuery=${encodeURIComponent(
|
||||
JSON.stringify(query),
|
||||
)}`,
|
||||
`/dashboard/${TEST_DASHBOARD_ID}/new?graphType=${panelTypeParam}&widgetId=${widgetId}&${serialize(
|
||||
query,
|
||||
).toString()}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { clearSerializedParams } from 'lib/compositeQuery/serializer';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
|
||||
import { isEmpty, isEqual } from 'lodash-es';
|
||||
@@ -384,7 +385,7 @@ function FormAlertRules({
|
||||
|
||||
const onCancelHandler = useCallback(
|
||||
(e?: React.MouseEvent) => {
|
||||
urlQuery.delete(QueryParams.compositeQuery);
|
||||
clearSerializedParams(urlQuery);
|
||||
urlQuery.delete(QueryParams.panelTypes);
|
||||
urlQuery.delete(QueryParams.ruleId);
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
@@ -610,7 +611,7 @@ function FormAlertRules({
|
||||
`${ruleId}`,
|
||||
]);
|
||||
|
||||
urlQuery.delete(QueryParams.compositeQuery);
|
||||
clearSerializedParams(urlQuery);
|
||||
urlQuery.delete(QueryParams.panelTypes);
|
||||
urlQuery.delete(QueryParams.ruleId);
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
|
||||
@@ -23,6 +23,10 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import {
|
||||
clearSerializedParams,
|
||||
serializeToParams,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import {
|
||||
@@ -213,9 +217,7 @@ function WidgetGraphComponent({
|
||||
[QueryParams.graphType]: clonedWidget?.panelTypes,
|
||||
[QueryParams.widgetId]: uuid,
|
||||
...(clonedWidget?.query && {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(
|
||||
JSON.stringify(clonedWidget.query),
|
||||
),
|
||||
...serializeToParams(clonedWidget.query),
|
||||
}),
|
||||
};
|
||||
safeNavigate(`${pathname}/new?${createQueryParams(queryParams)}`);
|
||||
@@ -256,7 +258,7 @@ function WidgetGraphComponent({
|
||||
const onToggleModelHandler = (): void => {
|
||||
const existingSearchParams = new URLSearchParams(search);
|
||||
existingSearchParams.delete(QueryParams.expandedWidgetId);
|
||||
existingSearchParams.delete(QueryParams.compositeQuery);
|
||||
clearSerializedParams(existingSearchParams);
|
||||
existingSearchParams.delete(QueryParams.graphType);
|
||||
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
|
||||
if (queryResponse.data?.payload) {
|
||||
|
||||
@@ -29,6 +29,10 @@ import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { unparse } from 'papaparse';
|
||||
@@ -86,10 +90,7 @@ function WidgetHeader({
|
||||
const widgetId = widget.id;
|
||||
urlQuery.set(QueryParams.widgetId, widgetId);
|
||||
urlQuery.set(QueryParams.graphType, widget.panelTypes);
|
||||
urlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(widget.query)),
|
||||
);
|
||||
applySerializedParams(serialize(widget.query), urlQuery);
|
||||
const generatedUrl = buildAbsolutePath({
|
||||
relativePath: 'new',
|
||||
urlQueryString: urlQuery.toString(),
|
||||
|
||||
@@ -7,6 +7,10 @@ import { useListRules } from 'api/generated/services/rules';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import history from 'lib/history';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
|
||||
@@ -134,10 +138,7 @@ export default function AlertRules({
|
||||
const compositeQuery = mapQueryDataFromApi(
|
||||
toCompositeMetricQuery(record.condition.compositeQuery),
|
||||
);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
applySerializedParams(serialize(compositeQuery), params);
|
||||
|
||||
const panelType = record.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import {
|
||||
@@ -403,7 +407,7 @@ export default function K8sBaseDetails<T>({
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
applySerializedParams(serialize(compositeQuery as any), urlQuery);
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
|
||||
} else if (selectedView === VIEW_TYPES.TRACES) {
|
||||
@@ -428,7 +432,7 @@ export default function K8sBaseDetails<T>({
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
applySerializedParams(serialize(compositeQuery as any), urlQuery);
|
||||
|
||||
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { serializeToParams } from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -185,7 +186,7 @@ function EntityLogsContent({
|
||||
[QueryParams.activeLogId]: `"${log?.id}"`,
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
[QueryParams.compositeQuery]: JSON.stringify({
|
||||
...serializeToParams({
|
||||
...baseQuery,
|
||||
builder: {
|
||||
...baseQuery.builder,
|
||||
|
||||
@@ -23,7 +23,12 @@ import {
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import {
|
||||
LogsAggregatorOperator,
|
||||
TracesAggregatorOperator,
|
||||
@@ -192,7 +197,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
@@ -206,7 +211,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
applySerializedParams(serialize(compositeQuery), urlQuery);
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
|
||||
} else if (selectedView === VIEW_TYPES.TRACES) {
|
||||
@@ -217,7 +222,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
@@ -231,7 +236,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
applySerializedParams(serialize(compositeQuery), urlQuery);
|
||||
|
||||
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,12 @@ import TanStackTable, {
|
||||
TableColumnDef,
|
||||
TanStackTableStateProvider,
|
||||
} from 'components/TanStackTableView';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { CornerDownRight } from '@signozhq/icons';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -259,10 +262,7 @@ export function K8sExpandedRow<
|
||||
};
|
||||
|
||||
const newUrlQuery = new URLSearchParams(urlQuery.toString());
|
||||
newUrlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(updatedQuery)),
|
||||
);
|
||||
applySerializedParams(serialize(updatedQuery), newUrlQuery);
|
||||
|
||||
safeNavigate(`${location.pathname}?${newUrlQuery.toString()}`);
|
||||
};
|
||||
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraFilterCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { useGlobalTimeQueryInvalidate } from 'store/globalTime';
|
||||
@@ -85,10 +88,7 @@ function K8sHeader({
|
||||
|
||||
// Use window.location.search to get fresh URL params (avoids stale hook state)
|
||||
const newUrlQuery = new URLSearchParams(window.location.search);
|
||||
newUrlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(updatedQuery)),
|
||||
);
|
||||
applySerializedParams(serialize(updatedQuery), newUrlQuery);
|
||||
|
||||
safeNavigate(`${location.pathname}?${newUrlQuery.toString()}`);
|
||||
void invalidateQueries();
|
||||
|
||||
@@ -7,6 +7,10 @@ import { logInfraExplorerNavigatedEvent } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -73,10 +77,7 @@ export function EntityCountsSection<T>({
|
||||
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
|
||||
urlParams.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
applySerializedParams(serialize(compositeQuery), urlParams);
|
||||
|
||||
const currentSearchParams = new URLSearchParams(window.location.search);
|
||||
const detailRelativeTime = currentSearchParams.get(
|
||||
|
||||
@@ -48,6 +48,7 @@ import styles from './EntityLogs.module.scss';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { serializeToParams } from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -185,7 +186,7 @@ function EntityLogsContent({
|
||||
[QueryParams.activeLogId]: `"${log?.id}"`,
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
[QueryParams.compositeQuery]: JSON.stringify({
|
||||
...serializeToParams({
|
||||
...baseQuery,
|
||||
builder: {
|
||||
...baseQuery.builder,
|
||||
|
||||
@@ -55,6 +55,7 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useGetGlobalConfig } from 'api/generated/services/global';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
UpdateLimitProps,
|
||||
} from 'types/api/ingestionKeys/limits/types';
|
||||
import { PaginationProps } from 'types/api/ingestionKeys/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MeterAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getDaysUntilExpiry } from 'utils/timeUtils';
|
||||
@@ -898,8 +900,6 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
},
|
||||
};
|
||||
|
||||
const stringifiedQuery = JSON.stringify(query);
|
||||
|
||||
const thresholds = cloneDeep(INITIAL_ALERT_THRESHOLD_STATE.thresholds);
|
||||
thresholds[0].thresholdValue = thresholdValue;
|
||||
thresholds[0].unit = thresholdUnit;
|
||||
@@ -909,20 +909,15 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
? `[ingestion][${signal.signal}] ${keyName} has exceeded daily ingestion limit`
|
||||
: `[ingestion][${signal.signal}] ${signal.signal} has exceeded daily ingestion limit`;
|
||||
|
||||
const params = serialize(query as Query);
|
||||
params.set(QueryParams.thresholds, JSON.stringify(thresholds));
|
||||
params.set(QueryParams.ruleName, ruleName);
|
||||
params.set(QueryParams.yAxisUnit, yAxisUnit);
|
||||
// Declare the metering prefill explicitly: "in total" + the meter window.
|
||||
const URL = `${ROUTES.ALERTS_NEW}?${
|
||||
QueryParams.compositeQuery
|
||||
}=${encodeURIComponent(stringifiedQuery)}&${
|
||||
QueryParams.thresholds
|
||||
}=${encodeURIComponent(JSON.stringify(thresholds))}&${
|
||||
QueryParams.ruleName
|
||||
}=${encodeURIComponent(ruleName)}&${
|
||||
QueryParams.yAxisUnit
|
||||
}=${encodeURIComponent(yAxisUnit)}&${QueryParams.matchType}=${
|
||||
AlertThresholdMatchType.IN_TOTAL
|
||||
}&${QueryParams.evaluationWindowPreset}=${EvaluationWindowPreset.METER}`;
|
||||
params.set(QueryParams.matchType, AlertThresholdMatchType.IN_TOTAL);
|
||||
params.set(QueryParams.evaluationWindowPreset, EvaluationWindowPreset.METER);
|
||||
|
||||
history.push(URL);
|
||||
history.push(`${ROUTES.ALERTS_NEW}?${params.toString()}`);
|
||||
};
|
||||
|
||||
const columns: AntDTableProps<GatewaytypesIngestionKeyDTO>['columns'] = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GatewaytypesGettableIngestionKeysDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
fireEvent,
|
||||
@@ -132,17 +133,19 @@ describe('MultiIngestionSettings Page', () => {
|
||||
expect(thresholds[0].thresholdValue).toBe(1000);
|
||||
expect(thresholds[0].unit).toBe('{count}');
|
||||
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
expect(compositeQuery.unit).toBe('{count}');
|
||||
expect(compositeQuery.builder.queryData).toBeDefined();
|
||||
const compositeQuery = deserialize(urlParams);
|
||||
expect(compositeQuery).not.toBeNull();
|
||||
expect(compositeQuery?.unit).toBe('{count}');
|
||||
expect(compositeQuery?.builder.queryData).toBeDefined();
|
||||
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filter.expression).toContain(
|
||||
const firstQueryData = compositeQuery?.builder.queryData[0];
|
||||
expect(firstQueryData?.filter?.expression).toContain(
|
||||
"signoz.workspace.key.id='k1'",
|
||||
);
|
||||
expect(firstQueryData.aggregations[0].metricName).toBe(
|
||||
const firstAggregation = firstQueryData?.aggregations?.[0] as {
|
||||
metricName: string;
|
||||
};
|
||||
expect(firstAggregation.metricName).toBe(
|
||||
'signoz.meter.metric.datapoint.count',
|
||||
);
|
||||
|
||||
@@ -213,18 +216,18 @@ describe('MultiIngestionSettings Page', () => {
|
||||
expect(thresholds[0].thresholdValue).toBe(400);
|
||||
expect(thresholds[0].unit).toBe('GiBy');
|
||||
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
expect(compositeQuery.unit).toBe('bytes');
|
||||
const compositeQuery = deserialize(urlParams);
|
||||
expect(compositeQuery).not.toBeNull();
|
||||
expect(compositeQuery?.unit).toBe('bytes');
|
||||
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filter.expression).toContain(
|
||||
const firstQueryData = compositeQuery?.builder.queryData[0];
|
||||
expect(firstQueryData?.filter?.expression).toContain(
|
||||
"signoz.workspace.key.id='k2'",
|
||||
);
|
||||
expect(firstQueryData.aggregations[0].metricName).toBe(
|
||||
'signoz.meter.log.size',
|
||||
);
|
||||
const firstAggregation = firstQueryData?.aggregations?.[0] as {
|
||||
metricName: string;
|
||||
};
|
||||
expect(firstAggregation.metricName).toBe('signoz.meter.log.size');
|
||||
|
||||
expect(urlParams.get(QueryParams.yAxisUnit)).toBe('bytes');
|
||||
expect(urlParams.get(QueryParams.ruleName)).toContain('logs');
|
||||
|
||||
@@ -6,6 +6,10 @@ import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useTableRowClick } from 'hooks/useTableRowClick';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
@@ -31,10 +35,7 @@ export function useAlertRulesHandlers(
|
||||
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
|
||||
rule.alertType,
|
||||
);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
applySerializedParams(serialize(compositeQuery), params);
|
||||
|
||||
const panelType = rule.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
|
||||
@@ -14,6 +14,10 @@ import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
@@ -111,10 +115,7 @@ function ContextLogRenderer({
|
||||
(logId: string): void => {
|
||||
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
|
||||
|
||||
urlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
applySerializedParams(serialize(query), urlQuery);
|
||||
|
||||
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
|
||||
window.open(withBasePath(link), '_blank', 'noopener,noreferrer');
|
||||
|
||||
@@ -247,16 +247,12 @@ function Application(): JSX.Element {
|
||||
const avialableParams = routeConfig[ROUTES.TRACE];
|
||||
const queryString = getQueryString(avialableParams, urlParams);
|
||||
|
||||
const JSONCompositeQuery = encodeURIComponent(
|
||||
JSON.stringify(apmToTraceQuery),
|
||||
);
|
||||
|
||||
const newPath = generateExplorerPath(
|
||||
isViewLogsClicked,
|
||||
urlParams,
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
JSONCompositeQuery,
|
||||
apmToTraceQuery,
|
||||
queryString,
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useClickOutside from 'hooks/useClickOutside';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
|
||||
import { traceFilterKeys } from 'pages/TracesExplorer/Filter/filterUtils';
|
||||
@@ -60,16 +64,18 @@ export function generateExplorerPath(
|
||||
urlParams: URLSearchParams,
|
||||
servicename: string | undefined,
|
||||
selectedTraceTags: string,
|
||||
JSONCompositeQuery: string,
|
||||
apmToTraceQuery: Query,
|
||||
queryString: string[],
|
||||
): string {
|
||||
const basePath = isViewLogsClicked
|
||||
? ROUTES.LOGS_EXPLORER
|
||||
: ROUTES.TRACES_EXPLORER;
|
||||
|
||||
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${JSONCompositeQuery}&${queryString.join('&')}`;
|
||||
applySerializedParams(serialize(apmToTraceQuery), urlParams);
|
||||
|
||||
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${queryString.join(
|
||||
'&',
|
||||
)}`;
|
||||
}
|
||||
|
||||
// TODO(@rahul-signoz): update the name of this function once we have view logs button in every panel
|
||||
@@ -105,16 +111,12 @@ export function onViewTracePopupClick({
|
||||
const avialableParams = routeConfig[ROUTES.TRACE];
|
||||
const queryString = getQueryString(avialableParams, urlParams);
|
||||
|
||||
const JSONCompositeQuery = encodeURIComponent(
|
||||
JSON.stringify(apmToTraceQuery),
|
||||
);
|
||||
|
||||
const newPath = generateExplorerPath(
|
||||
isViewLogsClicked,
|
||||
urlParams,
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
JSONCompositeQuery,
|
||||
apmToTraceQuery,
|
||||
queryString,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import { TopOperationList } from './TopOperationsTable';
|
||||
@@ -29,13 +30,11 @@ export const navigateToTrace = ({
|
||||
);
|
||||
urlParams.set(QueryParams.endTime, Math.floor(maxTime / 1_000_000).toString());
|
||||
|
||||
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(apmToTraceQuery));
|
||||
|
||||
const newTraceExplorerPath = `${
|
||||
ROUTES.TRACES_EXPLORER
|
||||
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${JSONCompositeQuery}`;
|
||||
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${serialize(
|
||||
apmToTraceQuery,
|
||||
).toString()}`;
|
||||
|
||||
if (openInNewTab) {
|
||||
window.open(withBasePath(newTraceExplorerPath), '_blank');
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { serializeToParams } from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||
@@ -791,9 +792,7 @@ function NewWidget({
|
||||
const queryParams = {
|
||||
[QueryParams.expandedWidgetId]: widgetId,
|
||||
[QueryParams.graphType]: graphType,
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(
|
||||
JSON.stringify(currentQuery),
|
||||
),
|
||||
...serializeToParams(currentQuery),
|
||||
[QueryParams.variables]: variables,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Button } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import store from 'store';
|
||||
@@ -189,12 +190,7 @@ describe('TableDrilldown', () => {
|
||||
// Parse the URL to check query parameters
|
||||
const urlObj = new URL(url, 'http://localhost');
|
||||
|
||||
// Check that compositeQuery parameter exists and contains the query with filters
|
||||
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
|
||||
|
||||
const compositeQuery = JSON.parse(
|
||||
decodeURIComponent(urlObj.searchParams.get('compositeQuery') || '{}'),
|
||||
);
|
||||
const compositeQuery = deserialize(urlObj.searchParams) as Query;
|
||||
|
||||
// Verify the query structure includes the filters from clicked data
|
||||
expect(compositeQuery.builder).toBeDefined();
|
||||
@@ -265,12 +261,7 @@ describe('TableDrilldown', () => {
|
||||
// Parse the URL to check query parameters
|
||||
const urlObj = new URL(url, 'http://localhost');
|
||||
|
||||
// Check that compositeQuery parameter exists and contains the query with filters
|
||||
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
|
||||
|
||||
const compositeQuery = JSON.parse(
|
||||
decodeURIComponent(urlObj.searchParams.get('compositeQuery') || '{}'),
|
||||
);
|
||||
const compositeQuery = deserialize(urlObj.searchParams) as Query;
|
||||
// Verify the query structure includes the filters from clicked data
|
||||
expect(compositeQuery.builder).toBeDefined();
|
||||
expect(compositeQuery.builder.queryData).toBeDefined();
|
||||
@@ -278,7 +269,7 @@ describe('TableDrilldown', () => {
|
||||
// Check that the query contains the correct filter expression
|
||||
// The filter should include the clicked data filters (service.name = 'adservice', trace_id = 'df2cfb0e57bb8736207689851478cd50')
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filter.expression).toStrictEqual(
|
||||
expect(firstQueryData.filter?.expression).toStrictEqual(
|
||||
MOCK_QUERY_WITH_FILTER,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { serializeToParams } from 'lib/compositeQuery/serializer';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -49,7 +50,7 @@ const useBaseDrilldownNavigate = ({
|
||||
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
let queryParams: Record<string, string> = {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...serializeToParams(viewQuery),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
@@ -94,7 +95,7 @@ export function buildDrilldownUrl(
|
||||
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
let queryParams: Record<string, string> = {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...serializeToParams(viewQuery),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
|
||||
@@ -26,7 +26,6 @@ import { useCreateEditRolePageActions } from './useCreateEditRolePageActions';
|
||||
|
||||
import styles from './CreateEditRolePage.module.scss';
|
||||
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
|
||||
function authzCheckFn(
|
||||
_props: object,
|
||||
@@ -34,11 +33,18 @@ function authzCheckFn(
|
||||
): BrandedPermission[] {
|
||||
const match = router.matchPath<{ roleId: string }>(ROUTES.ROLE_DETAILS);
|
||||
const roleId = match?.roleId ?? 'new';
|
||||
const roleName = router.searchParams.get('name') ?? '';
|
||||
const isCreateMode = roleId === 'new';
|
||||
|
||||
if (isCreateMode) {
|
||||
return [RoleCreatePermission];
|
||||
}
|
||||
if (roleName) {
|
||||
return [
|
||||
buildRoleReadPermission(roleName),
|
||||
buildRoleUpdatePermission(roleName),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -143,7 +149,7 @@ function CreateEditRolePageContent(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
if (isFeatureGateLoading) {
|
||||
if ((isLoading && !isCreateMode) || isFeatureGateLoading) {
|
||||
return (
|
||||
<div className={styles.createEditRolePage}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
@@ -151,18 +157,32 @@ function CreateEditRolePageContent(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const title = isCreateMode
|
||||
? 'Create Role'
|
||||
: `Role - ${
|
||||
formData.name || (isLoading ? 'Loading role...' : 'Failed to load role')
|
||||
}`;
|
||||
if (loadError) {
|
||||
return (
|
||||
<div
|
||||
className={styles.createEditRolePage}
|
||||
data-testid="create-edit-role-page"
|
||||
>
|
||||
<div className={styles.createEditRolePageHeader}>
|
||||
<div className={styles.createEditRolePageHeaderLeft}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
data-testid="cancel-button"
|
||||
className={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
<Typography.Title level={3}>Failed to load role</Typography.Title>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
const canCheckRolePermissions = !isCreateMode && !!roleName;
|
||||
|
||||
const saveChecks = isCreateMode
|
||||
? [RoleCreatePermission]
|
||||
: [buildRoleReadPermission(roleName), buildRoleUpdatePermission(roleName)];
|
||||
const isSaveCheckEnabled = isCreateMode || canCheckRolePermissions;
|
||||
<ErrorInPlace error={loadError} data-testid="role-load-error-banner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -181,7 +201,11 @@ function CreateEditRolePageContent(): JSX.Element {
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
<Typography.Title level={3}>{title}</Typography.Title>
|
||||
<Typography.Title level={3}>
|
||||
{isCreateMode
|
||||
? 'Create Role'
|
||||
: `Role - ${formData.name || 'Loading role...'}`}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<div className={styles.createEditRolePageActions}>
|
||||
@@ -194,8 +218,11 @@ function CreateEditRolePageContent(): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
<AuthZButton
|
||||
checks={saveChecks}
|
||||
authZEnabled={isSaveCheckEnabled}
|
||||
checks={
|
||||
isCreateMode
|
||||
? [RoleCreatePermission]
|
||||
: [buildRoleUpdatePermission(roleName)]
|
||||
}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSaveAndNavigate}
|
||||
@@ -208,91 +235,61 @@ function CreateEditRolePageContent(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthZGuardContent
|
||||
checks={canCheckRolePermissions ? [buildRoleReadPermission(roleName)] : []}
|
||||
fallbackOnLoading={
|
||||
<div className={styles.createEditRolePage}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className={styles.createEditRolePage}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
)}
|
||||
{saveError && (
|
||||
<ErrorInPlace
|
||||
error={saveError}
|
||||
height="auto"
|
||||
data-testid="save-error-banner"
|
||||
padding={0}
|
||||
bordered={true}
|
||||
className={styles.errorInPlaceContainer}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loadError && (
|
||||
<ErrorInPlace
|
||||
error={loadError}
|
||||
height="auto"
|
||||
data-testid="role-load-error-banner"
|
||||
padding={0}
|
||||
bordered={true}
|
||||
className={styles.errorInPlaceContainer}
|
||||
/>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<ErrorInPlace
|
||||
error={saveError}
|
||||
height="auto"
|
||||
data-testid="save-error-banner"
|
||||
padding={0}
|
||||
bordered={true}
|
||||
className={styles.errorInPlaceContainer}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.createEditRolePageContent}>
|
||||
<div className={styles.createEditRolePageForm}>
|
||||
<div className={styles.formRow}>
|
||||
{isCreateMode ? (
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-name" className={styles.formLabel}>
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="role-name"
|
||||
value={formData.name}
|
||||
onChange={(e): void => handleFormChange('name', e.target.value)}
|
||||
placeholder="my-custom-role"
|
||||
data-testid="role-name-input"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-description" className={styles.formLabel}>
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
id="role-description"
|
||||
value={formData.description}
|
||||
onChange={(e): void =>
|
||||
handleFormChange('description', e.target.value)
|
||||
}
|
||||
placeholder="Custom role for the support team"
|
||||
data-testid="role-description-input"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.createEditRolePageContent}>
|
||||
<div className={styles.createEditRolePageForm}>
|
||||
<div className={styles.formRow}>
|
||||
{isCreateMode ? (
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-name" className={styles.formLabel}>
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="role-name"
|
||||
value={formData.name}
|
||||
onChange={(e): void => handleFormChange('name', e.target.value)}
|
||||
placeholder="my-custom-role"
|
||||
data-testid="role-name-input"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-description" className={styles.formLabel}>
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
id="role-description"
|
||||
value={formData.description}
|
||||
onChange={(e): void => handleFormChange('description', e.target.value)}
|
||||
placeholder="Custom role for the support team"
|
||||
data-testid="role-description-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.createEditRolePageDivider} />
|
||||
|
||||
<PermissionEditor
|
||||
resources={resources}
|
||||
mode={editorMode}
|
||||
onModeChange={setEditorMode}
|
||||
onResourceChange={setResources}
|
||||
onJsonValidityChange={setHasJsonError}
|
||||
isLoading={isLoading}
|
||||
validationErrors={validationErrors}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</AuthZGuardContent>
|
||||
</div>
|
||||
|
||||
<div className={styles.createEditRolePageDivider} />
|
||||
|
||||
<PermissionEditor
|
||||
resources={resources}
|
||||
mode={editorMode}
|
||||
onModeChange={setEditorMode}
|
||||
onResourceChange={setResources}
|
||||
onJsonValidityChange={setHasJsonError}
|
||||
isLoading={isLoading}
|
||||
validationErrors={validationErrors}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isBlocked}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDenyAll,
|
||||
setupAuthzDeny,
|
||||
@@ -58,80 +57,24 @@ function renderEditPage(): ReturnType<typeof render> {
|
||||
|
||||
describe('EditRolePage - AuthZ', () => {
|
||||
describe('permission denied', () => {
|
||||
it('shows PermissionDeniedCallout when read permission denied', async () => {
|
||||
it('shows PermissionDeniedFullPage when read permission denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await expect(
|
||||
screen.findByText(/is not authorized to perform/i),
|
||||
screen.findByText(/You are not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByText('Uh-oh! You are not authorized'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the header visible and hides the form when read permission denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await screen.findByText(/is not authorized to perform/i);
|
||||
|
||||
await expect(
|
||||
screen.findByText(`Role - ${EDIT_ROLE_NAME}`),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('save-button')).toBeDisabled();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('role-description-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('permission-editor')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders page with disabled save button when update permission denied but read granted', async () => {
|
||||
it('shows PermissionDeniedFullPage when update permission denied but read granted', async () => {
|
||||
server.use(setupAuthzDeny(buildRoleUpdatePermission(EDIT_ROLE_NAME)));
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await expect(
|
||||
screen.findByText(`Role - ${EDIT_ROLE_NAME}`),
|
||||
screen.findByText(/You are not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const saveButton = await screen.findByTestId('save-button');
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('route without the name query param', () => {
|
||||
// `roleName` is the permission selector. When it is missing we must skip the
|
||||
// check entirely — building `role:` widens to `role:*` on the wire and never
|
||||
// matches back, which would deny the form even for an admin.
|
||||
it('renders the form instead of denying it', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_DETAILS}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{ initialRoute: `/settings/roles/${EDIT_ROLE_ID}` },
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('role-description-input'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByText(/is not authorized to perform/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,23 +96,6 @@ describe('EditRolePage - AuthZ', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission check failure', () => {
|
||||
it('renders the form when the permission check request fails', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('role-description-input'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/is not authorized to perform/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
it('renders edit page when both read and update permissions granted', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
@@ -71,20 +71,6 @@ describe('EditRolePage', () => {
|
||||
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading title in header while fetching role data', async () => {
|
||||
server.use(
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.delay(200), ctx.status(200), ctx.json(roleWithTransactionGroups)),
|
||||
),
|
||||
);
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await expect(
|
||||
screen.findByText('Role - Loading role...'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('load error state', () => {
|
||||
@@ -97,12 +83,12 @@ describe('EditRolePage', () => {
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('role-load-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows failed to load title on load error', async () => {
|
||||
it('shows Failed to load role title on load error', async () => {
|
||||
server.use(
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(404), ctx.json({ error: { message: 'Not found' } })),
|
||||
@@ -112,7 +98,7 @@ describe('EditRolePage', () => {
|
||||
renderEditPage();
|
||||
|
||||
await expect(
|
||||
screen.findByText('Role - Failed to load role'),
|
||||
screen.findByText('Failed to load role'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
|
||||
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
|
||||
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
@@ -267,7 +267,7 @@ function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default withAuthZContent<RolesListContentProps>(RolesListContent, {
|
||||
export default withAuthZPage<RolesListContentProps>(RolesListContent, {
|
||||
checks: [RoleListPermission],
|
||||
fallbackOnLoading: (
|
||||
<div className={styles.rolesListingTable}>
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDenyAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import RolesListingTable from '../RolesListingTable';
|
||||
|
||||
const rolesApiBase = '*/api/v1/roles';
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(rolesApiBase, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
function renderTable(): ReturnType<typeof render> {
|
||||
return render(<RolesListingTable searchQuery="" />, undefined, {
|
||||
initialRoute: '/settings/roles',
|
||||
});
|
||||
}
|
||||
|
||||
describe('RolesListingTable - AuthZ', () => {
|
||||
describe('permission granted', () => {
|
||||
it('renders the roles table when list permission granted', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
renderTable();
|
||||
|
||||
await expect(
|
||||
screen.findByText('billing-manager'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission denied', () => {
|
||||
it('shows inline permission denial instead of the table when list permission denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
renderTable();
|
||||
|
||||
await expect(
|
||||
screen.findByText(/is not authorized to perform/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('billing-manager')).not.toBeInTheDocument();
|
||||
|
||||
// denial is inline, not a full page takeover
|
||||
expect(
|
||||
screen.queryByText('Uh-oh! You are not authorized'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission check failure', () => {
|
||||
it('renders the roles table when the permission check request fails', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
|
||||
renderTable();
|
||||
|
||||
await expect(
|
||||
screen.findByText('billing-manager'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/is not authorized to perform/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,15 +5,22 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Skeleton } from 'antd';
|
||||
import { useGetRole } from 'api/generated/services/role';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { useDeleteRoleModal } from 'container/RolesSettings/DeleteRoleModal/useDeleteRoleModal';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { transformApiToRolePermissions } from 'container/RolesSettings/hooks/useRolePermissions';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
|
||||
import { buildRoleReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
|
||||
import { RouterContext } from 'lib/authz/components/withAuthZ/withAuthZ';
|
||||
import {
|
||||
buildRoleDeletePermission,
|
||||
buildRoleReadPermission,
|
||||
buildRoleUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
import { RoleType } from 'types/roles';
|
||||
@@ -25,35 +32,43 @@ import ReadOnlyJsonViewer from './ReadOnlyJsonViewer';
|
||||
import { useViewRolePageActions } from './useViewRolePageActions';
|
||||
|
||||
import styles from './ViewRolePage.module.scss';
|
||||
import { ViewRolePageHeaderActions } from 'container/RolesSettings/ViewRolePage/ViewRolePageHeaderActions';
|
||||
|
||||
interface ViewRoleContentProps {
|
||||
roleId: string;
|
||||
roleName: string;
|
||||
viewMode: 'list' | 'json';
|
||||
expandedResources: Set<string>;
|
||||
setExpandedResources: (resources: Set<string>) => void;
|
||||
handleModeChange: (value: string) => void;
|
||||
handleTabChange: (key: string) => void;
|
||||
activeTab: 'overview';
|
||||
}
|
||||
|
||||
function ViewRoleContentInner({
|
||||
roleId,
|
||||
viewMode,
|
||||
expandedResources,
|
||||
setExpandedResources,
|
||||
handleModeChange,
|
||||
handleTabChange,
|
||||
activeTab,
|
||||
}: ViewRoleContentProps): JSX.Element {
|
||||
function ViewRolePageContent(): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
|
||||
const { isRolesEnabled, isLoading: isFeatureGateLoading } =
|
||||
useRolesFeatureGate();
|
||||
|
||||
const {
|
||||
roleId,
|
||||
roleName,
|
||||
activeTab,
|
||||
viewMode,
|
||||
expandedResources,
|
||||
setExpandedResources,
|
||||
handleRedirectToUpdate,
|
||||
handleCancel,
|
||||
handleModeChange,
|
||||
handleTabChange,
|
||||
} = useViewRolePageActions();
|
||||
|
||||
const { data, isLoading, error } = useGetRole(
|
||||
{ id: roleId },
|
||||
{ id: roleId ?? '' },
|
||||
{ query: { enabled: !!roleId } },
|
||||
);
|
||||
const role = data?.data;
|
||||
const isManaged = role?.type === RoleType.MANAGED;
|
||||
|
||||
const {
|
||||
isDeleteModalOpen,
|
||||
deleteError,
|
||||
handleOpenDeleteModal,
|
||||
handleCloseDeleteModal,
|
||||
handleConfirmDelete,
|
||||
} = useDeleteRoleModal({
|
||||
roleId,
|
||||
isManaged: isManaged ?? false,
|
||||
onDeleteSuccess: handleCancel,
|
||||
});
|
||||
|
||||
const tabItems = useMemo(
|
||||
() => [
|
||||
@@ -101,7 +116,7 @@ function ViewRoleContentInner({
|
||||
<div className={styles.permissionContent}>
|
||||
{viewMode === 'list' ? (
|
||||
<PermissionOverview
|
||||
roleId={roleId}
|
||||
roleId={roleId ?? ''}
|
||||
expandedResources={expandedResources}
|
||||
onExpandedResourcesChange={setExpandedResources}
|
||||
/>
|
||||
@@ -123,109 +138,6 @@ function ViewRoleContentInner({
|
||||
],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton active paragraph={{ rows: 6 }} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorInPlace
|
||||
error={toAPIError(error, 'Failed to load role details')}
|
||||
data-testid="role-error-banner"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.viewRolePageContent}>
|
||||
<div className={styles.viewRolePageForm}>
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-description" className={styles.formLabel}>
|
||||
Description
|
||||
</label>
|
||||
<Typography>{role.description}</Typography>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-created-at" className={styles.formLabel}>
|
||||
Created At
|
||||
</label>
|
||||
<Badge color="secondary">
|
||||
{formatTimezoneAdjustedTimestampOptional(role.createdAt)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-modified-at" className={styles.formLabel}>
|
||||
Last Modified At
|
||||
</label>
|
||||
<Badge color="secondary">
|
||||
{formatTimezoneAdjustedTimestampOptional(role.updatedAt)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tabs
|
||||
className={styles.roleTabs}
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
items={tabItems}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ViewRoleContent = withAuthZContent<ViewRoleContentProps>(
|
||||
ViewRoleContentInner,
|
||||
{
|
||||
checks: (props: ViewRoleContentProps) =>
|
||||
props.roleName ? [buildRoleReadPermission(props.roleName)] : [],
|
||||
fallbackOnLoading: <Skeleton active paragraph={{ rows: 6 }} />,
|
||||
},
|
||||
);
|
||||
|
||||
function ViewRolePage(): JSX.Element {
|
||||
const { isRolesEnabled, isLoading: isFeatureGateLoading } =
|
||||
useRolesFeatureGate();
|
||||
|
||||
const {
|
||||
roleId,
|
||||
roleName,
|
||||
activeTab,
|
||||
viewMode,
|
||||
expandedResources,
|
||||
setExpandedResources,
|
||||
handleRedirectToUpdate,
|
||||
handleCancel,
|
||||
handleModeChange,
|
||||
handleTabChange,
|
||||
} = useViewRolePageActions();
|
||||
|
||||
const { data, isLoading: isRoleLoading } = useGetRole(
|
||||
{ id: roleId ?? '' },
|
||||
{ query: { enabled: !!roleId } },
|
||||
);
|
||||
const role = data?.data;
|
||||
const isManaged = role?.type === RoleType.MANAGED;
|
||||
|
||||
const {
|
||||
isDeleteModalOpen,
|
||||
deleteError,
|
||||
handleOpenDeleteModal,
|
||||
handleCloseDeleteModal,
|
||||
handleConfirmDelete,
|
||||
} = useDeleteRoleModal({
|
||||
roleId,
|
||||
isManaged: isManaged ?? false,
|
||||
onDeleteSuccess: handleCancel,
|
||||
});
|
||||
|
||||
if (!isRolesEnabled && !isFeatureGateLoading) {
|
||||
return (
|
||||
<div className={styles.viewRolePage} data-testid="view-role-page">
|
||||
@@ -263,7 +175,7 @@ function ViewRolePage(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
if (isFeatureGateLoading) {
|
||||
if (isLoading || isFeatureGateLoading) {
|
||||
return (
|
||||
<div className={styles.viewRolePage}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
@@ -271,6 +183,36 @@ function ViewRolePage(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.viewRolePage} data-testid="view-role-page">
|
||||
<div className={styles.viewRolePageHeader}>
|
||||
<div className={styles.viewRolePageHeaderLeft}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={handleCancel}
|
||||
data-testid="cancel-button"
|
||||
className={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
<Typography.Title level={3}>Failed to load role</Typography.Title>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorInPlace
|
||||
error={toAPIError(error, 'Failed to load role details')}
|
||||
data-testid="role-error-banner"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.viewRolePage} data-testid="view-role-page">
|
||||
<div className={styles.viewRolePageHeader}>
|
||||
@@ -285,35 +227,103 @@ function ViewRolePage(): JSX.Element {
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
<Typography.Title level={3}>
|
||||
{'Role - ' + (roleName || 'Loading role...')}
|
||||
{'Role - ' + role.name || 'Loading role...'}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<ViewRolePageHeaderActions
|
||||
isRoleLoading={isRoleLoading}
|
||||
isManaged={isManaged}
|
||||
roleName={roleName}
|
||||
handleOpenDeleteModal={handleOpenDeleteModal}
|
||||
handleRedirectToUpdate={handleRedirectToUpdate}
|
||||
/>
|
||||
<div className={styles.viewRolePageActions}>
|
||||
{isManaged ? (
|
||||
<TooltipSimple title="Managed roles cannot be deleted">
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
disabled
|
||||
data-testid="delete-button"
|
||||
className={styles.deleteButton}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
<AuthZButton
|
||||
checks={[buildRoleDeletePermission(roleName)]}
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={handleOpenDeleteModal}
|
||||
data-testid="delete-button"
|
||||
className={styles.deleteButton}
|
||||
>
|
||||
Delete
|
||||
</AuthZButton>
|
||||
)}
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
{isManaged ? (
|
||||
<TooltipSimple title="Managed roles cannot be updated">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled
|
||||
data-testid="save-button"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
<AuthZButton
|
||||
checks={[buildRoleUpdatePermission(roleName)]}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="save-button"
|
||||
onClick={handleRedirectToUpdate}
|
||||
>
|
||||
Update
|
||||
</AuthZButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{roleId && (
|
||||
<ViewRoleContent
|
||||
roleId={roleId}
|
||||
roleName={roleName}
|
||||
viewMode={viewMode}
|
||||
expandedResources={expandedResources}
|
||||
setExpandedResources={setExpandedResources}
|
||||
handleModeChange={handleModeChange}
|
||||
handleTabChange={handleTabChange}
|
||||
activeTab={activeTab}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.viewRolePageContent}>
|
||||
<div className={styles.viewRolePageForm}>
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-description" className={styles.formLabel}>
|
||||
Description
|
||||
</label>
|
||||
<Typography>{role.description}</Typography>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-created-at" className={styles.formLabel}>
|
||||
Created At
|
||||
</label>
|
||||
<Badge color="secondary">
|
||||
{formatTimezoneAdjustedTimestampOptional(role.createdAt)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label htmlFor="role-modified-at" className={styles.formLabel}>
|
||||
Last Modified At
|
||||
</label>
|
||||
<Badge color="secondary">
|
||||
{formatTimezoneAdjustedTimestampOptional(role.updatedAt)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tabs
|
||||
className={styles.roleTabs}
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
items={tabItems}
|
||||
/>
|
||||
</div>
|
||||
<DeleteRoleModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
roleName={roleName}
|
||||
roleName={role.name}
|
||||
error={deleteError}
|
||||
onCancel={handleCloseDeleteModal}
|
||||
onConfirm={handleConfirmDelete}
|
||||
@@ -322,4 +332,14 @@ function ViewRolePage(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewRolePage;
|
||||
export default withAuthZPage(ViewRolePageContent, {
|
||||
checks: (_props: object, router: RouterContext) => {
|
||||
const roleName = router.searchParams.get('name') ?? '';
|
||||
return roleName ? [buildRoleReadPermission(roleName)] : [];
|
||||
},
|
||||
fallbackOnLoading: (
|
||||
<div className={styles.viewRolePage}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import styles from 'container/RolesSettings/ViewRolePage/ViewRolePage.module.scss';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import {
|
||||
buildRoleDeletePermission,
|
||||
buildRoleReadPermission,
|
||||
buildRoleUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
export function ViewRolePageHeaderActions({
|
||||
isRoleLoading,
|
||||
isManaged,
|
||||
roleName,
|
||||
handleOpenDeleteModal,
|
||||
handleRedirectToUpdate,
|
||||
}: {
|
||||
isRoleLoading: boolean;
|
||||
isManaged: boolean;
|
||||
roleName: string;
|
||||
handleOpenDeleteModal: () => void;
|
||||
handleRedirectToUpdate: () => void;
|
||||
}): JSX.Element {
|
||||
const renderDeleteButton = (): JSX.Element => {
|
||||
if (isRoleLoading) {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
disabled
|
||||
data-testid="delete-button"
|
||||
className={styles.deleteButton}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isManaged) {
|
||||
return (
|
||||
<TooltipSimple title="Managed roles cannot be deleted">
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
disabled
|
||||
data-testid="delete-button"
|
||||
className={styles.deleteButton}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthZButton
|
||||
checks={[buildRoleDeletePermission(roleName)]}
|
||||
authZEnabled={!!roleName}
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={handleOpenDeleteModal}
|
||||
data-testid="delete-button"
|
||||
className={styles.deleteButton}
|
||||
>
|
||||
Delete
|
||||
</AuthZButton>
|
||||
);
|
||||
};
|
||||
|
||||
const renderUpdateButton = (): JSX.Element => {
|
||||
if (isRoleLoading) {
|
||||
return (
|
||||
<Button variant="solid" color="primary" disabled data-testid="save-button">
|
||||
Update
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isManaged) {
|
||||
return (
|
||||
<TooltipSimple title="Managed roles cannot be updated">
|
||||
<Button variant="solid" color="primary" disabled data-testid="save-button">
|
||||
Update
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthZButton
|
||||
checks={[
|
||||
buildRoleReadPermission(roleName),
|
||||
buildRoleUpdatePermission(roleName),
|
||||
]}
|
||||
authZEnabled={!!roleName}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="save-button"
|
||||
onClick={handleRedirectToUpdate}
|
||||
>
|
||||
Update
|
||||
</AuthZButton>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.viewRolePageActions}>
|
||||
{renderDeleteButton()}
|
||||
<Divider type="vertical" />
|
||||
{renderUpdateButton()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
});
|
||||
|
||||
describe('permission denied', () => {
|
||||
it('shows inline permission denial when read permission denied but keeps header visible', async () => {
|
||||
it('shows permission denied page when read permission denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
@@ -49,144 +49,8 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('view-role-page'),
|
||||
screen.findByText(/You are not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByText(/is not authorized to perform/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByText('Uh-oh! You are not authorized'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the role content when read permission denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await screen.findByText(/is not authorized to perform/i);
|
||||
|
||||
expect(screen.queryByTestId('permission-view-mode')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Description')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('route without the name query param', () => {
|
||||
// `roleName` is the permission selector. When it is missing we must skip the
|
||||
// check entirely — building `role:` widens to `role:*` on the wire and never
|
||||
// matches back, which would deny the content even for an admin.
|
||||
it('renders the role content instead of denying it', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('permission-view-mode'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByText(/is not authorized to perform/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the action buttons enabled instead of gating them on an empty selector', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{ initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}` },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-button')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('save-button')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission check failure', () => {
|
||||
it('renders the role content when the permission check request fails', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('permission-view-mode'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/is not authorized to perform/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -78,9 +78,8 @@ describe('ViewRolePage - Edge Cases', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
// Wait for content to render (not just page wrapper)
|
||||
await expect(
|
||||
screen.findByTestId('permission-view-mode'),
|
||||
screen.findByTestId('view-role-page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||
@@ -112,9 +111,8 @@ describe('ViewRolePage - Edge Cases', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
// Wait for content to render (not just page wrapper)
|
||||
await expect(
|
||||
screen.findByTestId('permission-view-mode'),
|
||||
screen.findByTestId('view-role-page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as roleApi from 'api/generated/services/role';
|
||||
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
@@ -45,12 +45,12 @@ describe('ViewRolePage - Error State', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('role-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error state when API fails without role data', async () => {
|
||||
it('displays error state with title when API fails without role data', async () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
@@ -62,10 +62,12 @@ describe('ViewRolePage - Error State', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
// Error shows in content area after authz check passes
|
||||
await expect(
|
||||
screen.findByTestId('role-error-banner'),
|
||||
screen.findByText('Failed to load role'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows back button on error state', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
|
||||
@@ -36,24 +36,6 @@ describe('ViewRolePage - Loading State', () => {
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the header visible with delete disabled while fetching role', async () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('delete-button'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByTestId('delete-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not fetch when roleId is missing from URL', () => {
|
||||
const getRole = jest.spyOn(roleApi, 'useGetRole');
|
||||
|
||||
|
||||
@@ -18,9 +18,8 @@ import {
|
||||
} from './testUtils';
|
||||
|
||||
async function waitForPageReady(): Promise<void> {
|
||||
// Wait for content to render (after authz check passes)
|
||||
await expect(
|
||||
screen.findByTestId('permission-view-mode'),
|
||||
screen.findByTestId('view-role-page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
}
|
||||
|
||||
|
||||
@@ -144,10 +144,7 @@ export function mockQueryParams(
|
||||
}
|
||||
});
|
||||
|
||||
return Object.create(URLSearchParams.prototype, {
|
||||
toString: { value: (): string => realUrlQuery.toString() },
|
||||
get: { value: (key: string): string | null => realUrlQuery.get(key) },
|
||||
});
|
||||
return realUrlQuery;
|
||||
}
|
||||
|
||||
export function convertRoutingPolicyToApiResponse(
|
||||
|
||||
@@ -35,6 +35,11 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
|
||||
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import {
|
||||
applySerializedParams,
|
||||
deserialize,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import AutoRefresh from '../AutoRefreshV2';
|
||||
@@ -278,7 +283,7 @@ function DateTimeSelection({
|
||||
return `Refreshed ${secondsDiff} sec ago`;
|
||||
}, [maxTime, minTime, selectedTime]);
|
||||
|
||||
const getUpdatedCompositeQuery = useCallback((): string => {
|
||||
const getUpdatedCompositeQuery = useCallback((): URLSearchParams => {
|
||||
let updatedCompositeQuery = cloneDeep(currentQuery);
|
||||
updatedCompositeQuery.id = uuid();
|
||||
// Remove the filters
|
||||
@@ -299,7 +304,7 @@ function DateTimeSelection({
|
||||
})),
|
||||
},
|
||||
};
|
||||
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
|
||||
return serialize(updatedCompositeQuery);
|
||||
}, [currentQuery]);
|
||||
|
||||
const onSelectHandler = useCallback(
|
||||
@@ -334,9 +339,9 @@ function DateTimeSelection({
|
||||
// Remove Hidden Filters from URL query parameters on time change
|
||||
urlQuery.delete(QueryParams.activeLogId);
|
||||
|
||||
if (urlQuery.has(QueryParams.compositeQuery)) {
|
||||
const updatedCompositeQuery = getUpdatedCompositeQuery();
|
||||
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
|
||||
const staledQuery = deserialize(urlQuery);
|
||||
if (staledQuery) {
|
||||
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
|
||||
}
|
||||
|
||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||
@@ -424,9 +429,9 @@ function DateTimeSelection({
|
||||
urlQuery.set(QueryParams.endTime, endTime?.toDate().getTime().toString());
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
|
||||
if (urlQuery.has(QueryParams.compositeQuery)) {
|
||||
const updatedCompositeQuery = getUpdatedCompositeQuery();
|
||||
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
|
||||
const staledQuery = deserialize(urlQuery);
|
||||
if (staledQuery) {
|
||||
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
|
||||
}
|
||||
|
||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||
|
||||
170
frontend/src/hooks/__tests__/useSafeNavigate.utils.test.ts
Normal file
170
frontend/src/hooks/__tests__/useSafeNavigate.utils.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import {
|
||||
areUrlsEffectivelySame,
|
||||
isDefaultNavigation,
|
||||
} from 'hooks/useSafeNavigate.utils';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
const BASE = 'http://localhost';
|
||||
|
||||
const urlFrom = (pathname: string, params?: URLSearchParams): URL => {
|
||||
const search = params?.toString();
|
||||
const query = search ? `?${search}` : '';
|
||||
return new URL(`${pathname}${query}`, BASE);
|
||||
};
|
||||
|
||||
/** Build params containing the serialized `compositeQuery` plus any extras. */
|
||||
const withQuery = (
|
||||
query: Query,
|
||||
extra: Record<string, string> = {},
|
||||
): URLSearchParams => {
|
||||
const params = serialize(query);
|
||||
Object.entries(extra).forEach(([key, value]) => params.set(key, value));
|
||||
return params;
|
||||
};
|
||||
|
||||
describe('areUrlsEffectivelySame', () => {
|
||||
it('returns false when pathnames differ', () => {
|
||||
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/traces'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true for two identical param-less URLs', () => {
|
||||
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/logs'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when only the compositeQuery is present and identical', () => {
|
||||
const params = withQuery(initialQueriesMap.logs);
|
||||
expect(
|
||||
areUrlsEffectivelySame(
|
||||
urlFrom('/logs', params),
|
||||
urlFrom('/logs', new URLSearchParams(params.toString())),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Regression: a matching compositeQuery must NOT mask differences in other
|
||||
// params. Previously every param was compared via the decoded query, so any
|
||||
// two URLs sharing a compositeQuery were judged identical.
|
||||
it('returns false when compositeQuery matches but another param differs', () => {
|
||||
const url1 = urlFrom(
|
||||
'/logs',
|
||||
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
|
||||
);
|
||||
const url2 = urlFrom(
|
||||
'/logs',
|
||||
withQuery(initialQueriesMap.logs, { startTime: '2000' }),
|
||||
);
|
||||
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when compositeQuery matches but a param exists on only one URL', () => {
|
||||
const url1 = urlFrom(
|
||||
'/logs',
|
||||
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
|
||||
);
|
||||
const url2 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
|
||||
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores the volatile id when comparing compositeQuery', () => {
|
||||
const url1 = urlFrom(
|
||||
'/logs',
|
||||
withQuery({ ...initialQueriesMap.logs, id: 'id-1' }),
|
||||
);
|
||||
const url2 = urlFrom(
|
||||
'/logs',
|
||||
withQuery({ ...initialQueriesMap.logs, id: 'id-2' }),
|
||||
);
|
||||
expect(areUrlsEffectivelySame(url1, url2)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when compositeQuery is semantically different', () => {
|
||||
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
|
||||
const url2 = urlFrom('/metrics', withQuery(initialQueriesMap.metrics));
|
||||
// Force same pathname so only the query differs.
|
||||
expect(
|
||||
areUrlsEffectivelySame(
|
||||
url1,
|
||||
urlFrom('/logs', new URLSearchParams(url2.search)),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when compositeQuery exists on only one URL', () => {
|
||||
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
|
||||
const url2 = urlFrom('/logs');
|
||||
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
|
||||
});
|
||||
|
||||
it('compares non-compositeQuery params directly when no compositeQuery is present', () => {
|
||||
const same1 = urlFrom(
|
||||
'/logs',
|
||||
new URLSearchParams({ startTime: '1', endTime: '2' }),
|
||||
);
|
||||
const same2 = urlFrom(
|
||||
'/logs',
|
||||
new URLSearchParams({ startTime: '1', endTime: '2' }),
|
||||
);
|
||||
expect(areUrlsEffectivelySame(same1, same2)).toBe(true);
|
||||
|
||||
const diff = urlFrom(
|
||||
'/logs',
|
||||
new URLSearchParams({ startTime: '1', endTime: '3' }),
|
||||
);
|
||||
expect(areUrlsEffectivelySame(same1, diff)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to raw comparison when compositeQuery cannot be decoded', () => {
|
||||
const corrupt1 = urlFrom(
|
||||
'/logs',
|
||||
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
|
||||
);
|
||||
const corrupt2 = urlFrom(
|
||||
'/logs',
|
||||
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
|
||||
);
|
||||
expect(areUrlsEffectivelySame(corrupt1, corrupt2)).toBe(true);
|
||||
|
||||
const corrupt3 = urlFrom(
|
||||
'/logs',
|
||||
new URLSearchParams({ compositeQuery: '%7Bother' }),
|
||||
);
|
||||
expect(areUrlsEffectivelySame(corrupt1, corrupt3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDefaultNavigation', () => {
|
||||
it('returns false for different pathnames', () => {
|
||||
expect(isDefaultNavigation(urlFrom('/logs'), urlFrom('/traces'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when a clean URL gains params', () => {
|
||||
expect(
|
||||
isDefaultNavigation(
|
||||
urlFrom('/logs'),
|
||||
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when the target introduces a new param key', () => {
|
||||
expect(
|
||||
isDefaultNavigation(
|
||||
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
|
||||
urlFrom('/logs', new URLSearchParams({ startTime: '1', endTime: '2' })),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the target has no new param keys', () => {
|
||||
expect(
|
||||
isDefaultNavigation(
|
||||
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
|
||||
urlFrom('/logs', new URLSearchParams({ startTime: '9' })),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
@@ -13,6 +12,7 @@ import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSea
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { getGeneratedFilterQueryString } from 'lib/getGeneratedFilterQueryString';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -58,9 +58,14 @@ export const useActiveLog = (): UseActiveLog => {
|
||||
|
||||
const [activeLog, setActiveLog] = useState<ILog | null>(null);
|
||||
|
||||
// Close drawer/clear active log when query in URL changes
|
||||
// Close drawer/clear active log when query in URL changes. Track the decoded
|
||||
// query (not a single raw param) so it stays correct across serializer tiers
|
||||
// that explode the query into many keys.
|
||||
const urlQuery = useUrlQuery();
|
||||
const compositeQuery = urlQuery.get(QueryParams.compositeQuery) ?? '';
|
||||
const compositeQuery = useMemo(() => {
|
||||
const decoded = deserialize(urlQuery);
|
||||
return decoded ? JSON.stringify(decoded) : '';
|
||||
}, [urlQuery]);
|
||||
const prevQueryRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
||||
@@ -2,15 +2,17 @@ import { useMutation } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import useCreateAlerts from '../useCreateAlerts';
|
||||
|
||||
jest.mock('react-query', () => ({
|
||||
useMutation: jest.fn(),
|
||||
QueryClient: jest.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
@@ -79,14 +81,14 @@ const buildWidget = (queryType: EQueryType | undefined): Widgets =>
|
||||
},
|
||||
}) as unknown as Widgets;
|
||||
|
||||
const getCompositeQueryFromLastOpen = (): Record<string, unknown> => {
|
||||
const getCompositeQueryFromLastOpen = (): Query => {
|
||||
const [url] = (window.open as jest.Mock).mock.calls[0];
|
||||
const query = new URLSearchParams((url as string).split('?')[1]);
|
||||
const raw = query.get(QueryParams.compositeQuery);
|
||||
if (!raw) {
|
||||
const composite = deserialize(query);
|
||||
if (!composite) {
|
||||
throw new Error('compositeQuery not found in URL');
|
||||
}
|
||||
return JSON.parse(decodeURIComponent(raw));
|
||||
return composite;
|
||||
};
|
||||
|
||||
describe('useCreateAlerts', () => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
|
||||
let mockUrlQuery = new URLSearchParams();
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => mockUrlQuery,
|
||||
}));
|
||||
|
||||
describe('useGetCompositeQueryParam', () => {
|
||||
it('decodes a legacy compositeQuery param', () => {
|
||||
mockUrlQuery = new URLSearchParams({
|
||||
compositeQuery: encodeURIComponent(JSON.stringify(initialQueriesMap.logs)),
|
||||
});
|
||||
const { result } = renderHook(() => useGetCompositeQueryParam());
|
||||
expect(result.current?.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
|
||||
it('returns null when the param is absent', () => {
|
||||
mockUrlQuery = new URLSearchParams();
|
||||
const { result } = renderHook(() => useGetCompositeQueryParam());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,10 @@ import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
@@ -91,10 +95,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(updatedQuery)),
|
||||
);
|
||||
applySerializedParams(serialize(updatedQuery), params);
|
||||
params.set(QueryParams.panelTypes, widget.panelTypes);
|
||||
params.set(QueryParams.version, ENTITY_VERSION_V5);
|
||||
params.set(QueryParams.source, YAxisSource.DASHBOARDS);
|
||||
|
||||
@@ -1,72 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
convertHavingToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { useMemo } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null => {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
return useMemo(() => {
|
||||
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
|
||||
let parsedCompositeQuery: Query | null = null;
|
||||
|
||||
try {
|
||||
if (!compositeQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
|
||||
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
|
||||
parsedCompositeQuery = JSON.parse(
|
||||
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
|
||||
);
|
||||
|
||||
// Convert old format to new format for each query in builder.queryData
|
||||
if (parsedCompositeQuery?.builder?.queryData) {
|
||||
parsedCompositeQuery.builder.queryData =
|
||||
parsedCompositeQuery.builder.queryData.map((query) => {
|
||||
const existingExpression = query.filter?.expression || '';
|
||||
const convertedQuery = { ...query };
|
||||
|
||||
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
|
||||
query.filters || { items: [], op: 'AND' },
|
||||
existingExpression,
|
||||
);
|
||||
convertedQuery.filter = convertedFilter.filter;
|
||||
convertedQuery.filters = convertedFilter.filters;
|
||||
|
||||
// Convert having if needed
|
||||
if (Array.isArray(query.having)) {
|
||||
const convertedHaving = convertHavingToExpression(query.having);
|
||||
convertedQuery.having = convertedHaving;
|
||||
}
|
||||
|
||||
// Convert aggregation if needed
|
||||
if (!query.aggregations && query.aggregateOperator) {
|
||||
const convertedAggregation = convertAggregationToExpression({
|
||||
aggregateOperator: query.aggregateOperator,
|
||||
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
|
||||
dataSource: query.dataSource,
|
||||
timeAggregation: query.timeAggregation,
|
||||
spaceAggregation: query.spaceAggregation,
|
||||
reduceTo: query.reduceTo,
|
||||
temporality: query.temporality,
|
||||
}) as any; // Type assertion to handle union type
|
||||
convertedQuery.aggregations = convertedAggregation;
|
||||
}
|
||||
return convertedQuery;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
parsedCompositeQuery = null;
|
||||
}
|
||||
|
||||
return parsedCompositeQuery;
|
||||
}, [urlQuery]);
|
||||
return useMemo(() => deserialize(urlQuery), [urlQuery]);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
areUrlsEffectivelySame,
|
||||
isDefaultNavigation,
|
||||
} from 'hooks/useSafeNavigate.utils';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
interface NavigateOptions {
|
||||
@@ -18,77 +21,6 @@ interface UseSafeNavigateProps {
|
||||
preventSameUrlNavigation?: boolean;
|
||||
}
|
||||
|
||||
const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
|
||||
if (url1.pathname !== url2.pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params1 = new URLSearchParams(url1.search);
|
||||
const params2 = new URLSearchParams(url2.search);
|
||||
|
||||
const allParams = new Set([...params1.keys(), ...params2.keys()]);
|
||||
|
||||
return [...allParams].every((param) => {
|
||||
if (param === 'compositeQuery') {
|
||||
try {
|
||||
const query1 = params1.get('compositeQuery');
|
||||
const query2 = params2.get('compositeQuery');
|
||||
|
||||
if (!query1 || !query2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decoded1 = JSON.parse(decodeURIComponent(query1));
|
||||
const decoded2 = JSON.parse(decodeURIComponent(query2));
|
||||
|
||||
const filtered1 = cloneDeep(decoded1);
|
||||
const filtered2 = cloneDeep(decoded2);
|
||||
|
||||
delete filtered1.id;
|
||||
delete filtered2.id;
|
||||
|
||||
return isEqual(filtered1, filtered2);
|
||||
} catch (error) {
|
||||
console.warn('Error comparing compositeQuery:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return params1.get(param) === params2.get(param);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if this navigation is adding default/initial parameters
|
||||
* Returns true if:
|
||||
* 1. We're staying on the same page (same pathname)
|
||||
* 2. Either:
|
||||
* - Current URL has no params and target URL has params, or
|
||||
* - Target URL has new params that didn't exist in current URL
|
||||
*/
|
||||
const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
|
||||
// Different pathnames means it's not a default navigation
|
||||
if (currentUrl.pathname !== targetUrl.pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentParams = new URLSearchParams(currentUrl.search);
|
||||
const targetParams = new URLSearchParams(targetUrl.search);
|
||||
|
||||
// Case 1: Clean URL getting params for the first time
|
||||
if (!currentParams.toString() && targetParams.toString()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Case 2: Check for new params that didn't exist before
|
||||
const currentKeys = new Set(currentParams.keys());
|
||||
const targetKeys = new Set(targetParams.keys());
|
||||
|
||||
// Find keys that exist in target but not in current
|
||||
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
|
||||
|
||||
return newKeys.length > 0;
|
||||
};
|
||||
export const useSafeNavigate = (
|
||||
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
|
||||
preventSameUrlNavigation: true,
|
||||
|
||||
103
frontend/src/hooks/useSafeNavigate.utils.ts
Normal file
103
frontend/src/hooks/useSafeNavigate.utils.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/constants';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
/**
|
||||
* Compare the (optional) `compositeQuery` param of two URLSearchParams
|
||||
* semantically. Its serialized form is not byte-stable — the volatile `id` and
|
||||
* the adapter choice both vary — so we decode and deep-compare, ignoring `id`.
|
||||
*
|
||||
* compositeQuery is not guaranteed to be present: absent on both sides counts
|
||||
* as equal, present on only one side counts as different. When either side is
|
||||
* present but can't be decoded, we fall back to comparing the raw values.
|
||||
*/
|
||||
const compositeQueriesEqual = (
|
||||
params1: URLSearchParams,
|
||||
params2: URLSearchParams,
|
||||
): boolean => {
|
||||
const raw1 = params1.get(COMPOSITE_QUERY_KEY);
|
||||
const raw2 = params2.get(COMPOSITE_QUERY_KEY);
|
||||
|
||||
if (!raw1 && !raw2) {
|
||||
return true;
|
||||
}
|
||||
if (!raw1 || !raw2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded1 = deserialize(params1);
|
||||
const decoded2 = deserialize(params2);
|
||||
|
||||
if (decoded1 && decoded2) {
|
||||
// Ignore the volatile `id` when comparing queries.
|
||||
const { id: _id1, ...rest1 } = decoded1;
|
||||
const { id: _id2, ...rest2 } = decoded2;
|
||||
|
||||
return isEqual(rest1, rest2);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error comparing compositeQuery:', error);
|
||||
}
|
||||
|
||||
// One or both could not be decoded — compare the raw encoded values.
|
||||
return raw1 === raw2;
|
||||
};
|
||||
|
||||
export const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
|
||||
if (url1.pathname !== url2.pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params1 = new URLSearchParams(url1.search);
|
||||
const params2 = new URLSearchParams(url2.search);
|
||||
|
||||
// The compositeQuery is compared semantically (it round-trips through a
|
||||
// non-stable serialized form); every other param is compared by raw value.
|
||||
if (!compositeQueriesEqual(params1, params2)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const otherKeys = new Set(
|
||||
[...params1.keys(), ...params2.keys()].filter(
|
||||
(key) => key !== COMPOSITE_QUERY_KEY,
|
||||
),
|
||||
);
|
||||
|
||||
return [...otherKeys].every((key) => params1.get(key) === params2.get(key));
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if this navigation is adding default/initial parameters
|
||||
* Returns true if:
|
||||
* 1. We're staying on the same page (same pathname)
|
||||
* 2. Either:
|
||||
* - Current URL has no params and target URL has params, or
|
||||
* - Target URL has new params that didn't exist in current URL
|
||||
*/
|
||||
export const isDefaultNavigation = (
|
||||
currentUrl: URL,
|
||||
targetUrl: URL,
|
||||
): boolean => {
|
||||
// Different pathnames means it's not a default navigation
|
||||
if (currentUrl.pathname !== targetUrl.pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentParams = new URLSearchParams(currentUrl.search);
|
||||
const targetParams = new URLSearchParams(targetUrl.search);
|
||||
|
||||
// Case 1: Clean URL getting params for the first time
|
||||
if (!currentParams.toString() && targetParams.toString()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Case 2: Check for new params that didn't exist before
|
||||
const currentKeys = new Set(currentParams.keys());
|
||||
const targetKeys = new Set(targetParams.keys());
|
||||
|
||||
// Find keys that exist in target but not in current
|
||||
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
|
||||
|
||||
return newKeys.length > 0;
|
||||
};
|
||||
@@ -15,18 +15,12 @@ export type AuthZButtonProps = ButtonProps & {
|
||||
* Gate the permission check itself. When false, renders a plain button.
|
||||
*/
|
||||
authZEnabled?: boolean;
|
||||
/**
|
||||
* Set this false when this button is used inside a modal/drawer of signozhq/ui,
|
||||
* otherwise the tooltip will not have the correct z-index
|
||||
*/
|
||||
withPortal?: false;
|
||||
};
|
||||
|
||||
function AuthZButton({
|
||||
checks,
|
||||
tooltipMessage,
|
||||
authZEnabled = true,
|
||||
withPortal,
|
||||
...buttonProps
|
||||
}: AuthZButtonProps): JSX.Element {
|
||||
return (
|
||||
@@ -34,7 +28,6 @@ function AuthZButton({
|
||||
checks={checks}
|
||||
enabled={authZEnabled}
|
||||
tooltipMessage={tooltipMessage}
|
||||
withPortal={withPortal}
|
||||
>
|
||||
<Button {...buttonProps} />
|
||||
</AuthZTooltip>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { cloneElement, CSSProperties, ReactElement, useMemo } from 'react';
|
||||
import { CSSProperties, ReactElement, cloneElement, useMemo } from 'react';
|
||||
import {
|
||||
TooltipRoot,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
@@ -23,11 +23,6 @@ interface AuthZTooltipProps {
|
||||
children: ReactElement;
|
||||
enabled?: boolean;
|
||||
tooltipMessage?: string;
|
||||
/**
|
||||
* Set this false when this button is used inside a modal/drawer of signozhq/ui,
|
||||
* otherwise the tooltip will not have the correct z-index
|
||||
*/
|
||||
withPortal?: false;
|
||||
}
|
||||
|
||||
function formatDeniedMessage(
|
||||
@@ -47,7 +42,6 @@ function AuthZTooltip({
|
||||
children,
|
||||
enabled = true,
|
||||
tooltipMessage,
|
||||
withPortal,
|
||||
}: AuthZTooltipProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const shouldCheck = enabled && checks.length > 0;
|
||||
@@ -75,12 +69,10 @@ function AuthZTooltip({
|
||||
return children;
|
||||
}
|
||||
|
||||
const childTestId = (children.props as { testId?: string }).testId;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild testId={childTestId}>
|
||||
<TooltipTrigger asChild>
|
||||
{cloneElement(children, {
|
||||
disabled: true,
|
||||
style: DISABLED_STYLE,
|
||||
@@ -90,7 +82,7 @@ function AuthZTooltip({
|
||||
'data-denied-permissions': deniedPermissions.join(','),
|
||||
})}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={styles.errorContent} withPortal={withPortal}>
|
||||
<TooltipContent className={styles.errorContent}>
|
||||
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
|
||||
51
frontend/src/lib/compositeQuery/__tests__/serializer.test.ts
Normal file
51
frontend/src/lib/compositeQuery/__tests__/serializer.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/constants';
|
||||
import {
|
||||
clearSerializedParams,
|
||||
deserialize,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
|
||||
describe('composite query serializer', () => {
|
||||
it('round-trips through serialize/deserialize', () => {
|
||||
const query = initialQueriesMap.logs;
|
||||
const decoded = deserialize(serialize(query));
|
||||
expect(decoded?.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
|
||||
it('returns null on corrupt input instead of throwing', () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
|
||||
expect(deserialize(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty/missing value', () => {
|
||||
const params = new URLSearchParams();
|
||||
expect(deserialize(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves id field through roundtrip', () => {
|
||||
const query = { ...initialQueriesMap.metrics, id: 'test-query-uuid-123' };
|
||||
const serialized = serialize(query);
|
||||
const decoded = deserialize(serialized);
|
||||
expect(decoded?.id).toBe('test-query-uuid-123');
|
||||
});
|
||||
|
||||
it('clearSerializedParams purges every serialized key, leaving others intact', () => {
|
||||
const params = serialize(initialQueriesMap.logs);
|
||||
params.set('panelTypes', 'list');
|
||||
clearSerializedParams(params);
|
||||
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
|
||||
expect(deserialize(params)).toBeNull();
|
||||
expect(params.get('panelTypes')).toBe('list');
|
||||
});
|
||||
|
||||
it('clearSerializedParams drops a corrupt legacy key via fallback', () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
|
||||
params.set('panelTypes', 'list');
|
||||
clearSerializedParams(params);
|
||||
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
|
||||
expect(params.get('panelTypes')).toBe('list');
|
||||
});
|
||||
});
|
||||
74
frontend/src/lib/compositeQuery/adapters/json/index.ts
Normal file
74
frontend/src/lib/compositeQuery/adapters/json/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
convertHavingToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/constants';
|
||||
import type { CompositeQueryAdapter } from 'lib/compositeQuery/types';
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
function migrateLegacyFormat(parsed: Query): Query {
|
||||
if (!parsed?.builder?.queryData) {
|
||||
return parsed;
|
||||
}
|
||||
const next = parsed;
|
||||
next.builder.queryData = parsed.builder.queryData.map((query) => {
|
||||
const existingExpression = query.filter?.expression || '';
|
||||
const convertedQuery = { ...query };
|
||||
|
||||
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
|
||||
query.filters || { items: [], op: 'AND' },
|
||||
existingExpression,
|
||||
);
|
||||
convertedQuery.filter = convertedFilter.filter;
|
||||
convertedQuery.filters = convertedFilter.filters;
|
||||
|
||||
if (Array.isArray(query.having)) {
|
||||
convertedQuery.having = convertHavingToExpression(query.having);
|
||||
}
|
||||
|
||||
if (!query.aggregations && query.aggregateOperator) {
|
||||
convertedQuery.aggregations = convertAggregationToExpression({
|
||||
aggregateOperator: query.aggregateOperator,
|
||||
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
|
||||
dataSource: query.dataSource,
|
||||
timeAggregation: query.timeAggregation,
|
||||
spaceAggregation: query.spaceAggregation,
|
||||
reduceTo: query.reduceTo,
|
||||
temporality: query.temporality,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
}
|
||||
return convertedQuery;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export const jsonAdapter: CompositeQueryAdapter = {
|
||||
name: 'json(legacy)',
|
||||
encode: (query) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, JSON.stringify(query));
|
||||
return params;
|
||||
},
|
||||
matches: () => true,
|
||||
decode: (params) => {
|
||||
const raw = params.get(COMPOSITE_QUERY_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let parsed: Query;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
|
||||
}
|
||||
return migrateLegacyFormat(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
216
frontend/src/lib/compositeQuery/adapters/json/json.test.ts
Normal file
216
frontend/src/lib/compositeQuery/adapters/json/json.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/constants';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { jsonAdapter } from './index';
|
||||
|
||||
const roundTrip = (query: Query): Query => {
|
||||
const decoded = jsonAdapter.decode(jsonAdapter.encode(query));
|
||||
if (!decoded) {
|
||||
throw new Error('roundTrip: decode returned null');
|
||||
}
|
||||
return decoded;
|
||||
};
|
||||
|
||||
describe('jsonAdapter', () => {
|
||||
describe('round-trip', () => {
|
||||
it.each(['metrics', 'logs', 'traces'] as const)(
|
||||
'round-trips %s baseline preserving dataSource',
|
||||
(source) => {
|
||||
const query = initialQueriesMap[source];
|
||||
const decoded = roundTrip(query);
|
||||
expect(decoded?.builder.queryData[0].dataSource).toBe(source);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('encoding', () => {
|
||||
it('encodes using single URL encoding via URLSearchParams', () => {
|
||||
const query = initialQueriesMap.logs;
|
||||
const params = jsonAdapter.encode(query);
|
||||
const raw = params.get(COMPOSITE_QUERY_KEY) ?? '';
|
||||
|
||||
// URLSearchParams.get() returns decoded value, so raw === JSON string
|
||||
expect(raw).toBe(JSON.stringify(query));
|
||||
expect(raw.startsWith('{')).toBe(true);
|
||||
|
||||
// Full URL shows single encoding
|
||||
const fullUrl = params.toString();
|
||||
expect(fullUrl).toContain('%7B'); // encoded {
|
||||
expect(fullUrl).not.toContain('%257B'); // NOT double-encoded
|
||||
});
|
||||
|
||||
it('decode handles single-encoded format (current)', () => {
|
||||
const query = initialQueriesMap.logs;
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, JSON.stringify(query));
|
||||
|
||||
const decoded = jsonAdapter.decode(params);
|
||||
expect(decoded?.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy double-encoded fallback', () => {
|
||||
it('decode handles double-encoded format (legacy URLs)', () => {
|
||||
const query = initialQueriesMap.logs;
|
||||
// Simulate legacy: JSON -> encodeURIComponent -> set as raw param
|
||||
const doubleEncoded = encodeURIComponent(JSON.stringify(query));
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, doubleEncoded);
|
||||
|
||||
const decoded = jsonAdapter.decode(params);
|
||||
expect(decoded?.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
|
||||
it('double-encoded with special chars decodes correctly', () => {
|
||||
const queryWithSpecialChars = {
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.logs.builder.queryData[0],
|
||||
filters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
key: { key: 'message', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'hello world & foo=bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const doubleEncoded = encodeURIComponent(
|
||||
JSON.stringify(queryWithSpecialChars),
|
||||
);
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, doubleEncoded);
|
||||
|
||||
const decoded = jsonAdapter.decode(params);
|
||||
const filter = decoded?.builder.queryData[0].filters?.items[0];
|
||||
expect(filter?.value).toBe('hello world & foo=bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plus-sign handling', () => {
|
||||
it('plus signs in double-encoded URLs decode as spaces', () => {
|
||||
// In URL encoding, + represents space. Legacy URLs may have this.
|
||||
const query = { queryType: 'builder', test: 'hello world' };
|
||||
// Manually create double-encoded with + for space
|
||||
const jsonStr = JSON.stringify(query);
|
||||
const encoded = encodeURIComponent(jsonStr).replace(/%20/g, '+');
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, encoded);
|
||||
|
||||
const decoded = jsonAdapter.decode(params) as any;
|
||||
expect(decoded.test).toBe('hello world');
|
||||
});
|
||||
|
||||
it('plus signs in filter values preserved after decode', () => {
|
||||
// Value literally contains + (not space)
|
||||
const queryWithPlus = {
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.logs.builder.queryData[0],
|
||||
filters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
key: { key: 'expr', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: '1+2=3',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Current format (single encode) - + becomes %2B
|
||||
const params = jsonAdapter.encode(queryWithPlus as Query);
|
||||
const decoded = jsonAdapter.decode(params);
|
||||
expect(decoded?.builder.queryData[0].filters?.items[0]?.value).toBe('1+2=3');
|
||||
});
|
||||
|
||||
it('legacy double-encoded + in values preserved', () => {
|
||||
const queryWithPlus = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: {
|
||||
op: 'AND',
|
||||
items: [{ key: { key: 'x' }, op: '=', value: 'a+b' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: 'x',
|
||||
unit: '',
|
||||
};
|
||||
// Double encode: + in JSON becomes %2B, then %252B
|
||||
const doubleEncoded = encodeURIComponent(JSON.stringify(queryWithPlus));
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, doubleEncoded);
|
||||
|
||||
const decoded = jsonAdapter.decode(params);
|
||||
expect(decoded?.builder.queryData[0].filters?.items[0]?.value).toBe('a+b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag matching', () => {
|
||||
it('matches any value (catch-all fallback)', () => {
|
||||
const params1 = new URLSearchParams();
|
||||
params1.set(COMPOSITE_QUERY_KEY, '%7B%22queryType%22%3A%22builder%22%7D');
|
||||
expect(jsonAdapter.matches(params1)).toBe(true);
|
||||
|
||||
const params2 = new URLSearchParams();
|
||||
params2.set(COMPOSITE_QUERY_KEY, 'z1~abc');
|
||||
expect(jsonAdapter.matches(params2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration', () => {
|
||||
it('migrates old format (filters -> filter.expression)', () => {
|
||||
const legacy = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: { op: 'AND', items: [] },
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: '', dataType: '', type: '' },
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: 'x',
|
||||
unit: '',
|
||||
};
|
||||
const params = new URLSearchParams();
|
||||
params.set(COMPOSITE_QUERY_KEY, JSON.stringify(legacy));
|
||||
const decoded = jsonAdapter.decode(params);
|
||||
expect(decoded?.builder.queryData[0].filter).toBeDefined();
|
||||
expect(decoded?.builder.queryData[0].aggregations).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
frontend/src/lib/compositeQuery/constants.ts
Normal file
1
frontend/src/lib/compositeQuery/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const COMPOSITE_QUERY_KEY = 'compositeQuery';
|
||||
78
frontend/src/lib/compositeQuery/serializer.ts
Normal file
78
frontend/src/lib/compositeQuery/serializer.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { jsonAdapter } from 'lib/compositeQuery/adapters/json';
|
||||
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/constants';
|
||||
import type { CompositeQueryAdapter } from 'lib/compositeQuery/types';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
// Order matters for decode: most-specific (tagged) adapters first
|
||||
const ADAPTERS: CompositeQueryAdapter[] = [jsonAdapter];
|
||||
|
||||
// Pick the adapter that owns a given URL. json's `matches` is always true, so
|
||||
// it serves as the final fallback when no tagged adapter claims the params.
|
||||
function adapterFor(params: URLSearchParams): CompositeQueryAdapter {
|
||||
return ADAPTERS.find((adapter) => adapter.matches(params)) ?? jsonAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a query to the shortest available URLSearchParams.
|
||||
*/
|
||||
export function serialize(query: Query): URLSearchParams {
|
||||
return ADAPTERS[0].encode(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode URLSearchParams back to a Query. Total: returns null on any failure.
|
||||
*/
|
||||
export function deserialize(params: URLSearchParams): Query | null {
|
||||
const hasParams = params.toString().length > 0;
|
||||
if (!hasParams) {
|
||||
return null;
|
||||
}
|
||||
return adapterFor(params).decode(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all params from source into target URLSearchParams.
|
||||
*/
|
||||
export function applySerializedParams(
|
||||
source: URLSearchParams,
|
||||
target: URLSearchParams,
|
||||
): void {
|
||||
source.forEach((value, key) => target.set(key, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every serialized-query param from target URLSearchParams. Use instead
|
||||
* of `target.delete('compositeQuery')` so a stale query is fully purged even
|
||||
* for adapters that explode a query into many content-dependent keys (e.g.
|
||||
* `query0.ds`, `query0.fl.it.0.key.key`) which can't be listed statically.
|
||||
*
|
||||
* Keys are discovered by round-trip: decode the current params with their
|
||||
* owning adapter, re-encode, then delete exactly the keys encoding produces.
|
||||
* If the params don't decode (absent/corrupt), fall back to dropping the legacy
|
||||
* single key so a stale `compositeQuery` is still cleared.
|
||||
*/
|
||||
export function clearSerializedParams(target: URLSearchParams): void {
|
||||
const adapter = adapterFor(target);
|
||||
try {
|
||||
const decoded = adapter.decode(target);
|
||||
if (!decoded) {
|
||||
target.delete(COMPOSITE_QUERY_KEY);
|
||||
return;
|
||||
}
|
||||
adapter.encode(decoded).forEach((_value, key) => {
|
||||
target.delete(key);
|
||||
});
|
||||
} catch {
|
||||
target.delete(COMPOSITE_QUERY_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a query to a plain record of all URL params it produces. Use when
|
||||
* building a query-param object manually (e.g. for `createQueryParams`), so the
|
||||
* call site carries every param the adapter emits — not just `compositeQuery`.
|
||||
* Spread it: `{ ...serializeToParams(query), startTime, endTime }`.
|
||||
*/
|
||||
export function serializeToParams(query: Query): Record<string, string> {
|
||||
return Object.fromEntries(serialize(query));
|
||||
}
|
||||
13
frontend/src/lib/compositeQuery/types.ts
Normal file
13
frontend/src/lib/compositeQuery/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/**
|
||||
* A serialization tier. `encode` returns URLSearchParams (default key =
|
||||
* `compositeQuery`). `matches` checks if params belong to this adapter.
|
||||
* `decode` receives URLSearchParams and returns Query or null if missing/invalid.
|
||||
*/
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): URLSearchParams;
|
||||
matches(params: URLSearchParams): boolean;
|
||||
decode(params: URLSearchParams): Query | null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
@@ -49,13 +50,9 @@ describe('newPanelRoute', () => {
|
||||
return { path, params: new URLSearchParams(search) };
|
||||
};
|
||||
|
||||
// Mirrors useGetCompositeQueryParam: it decodes the param twice.
|
||||
const readCompositeQuery = (params: URLSearchParams): unknown =>
|
||||
JSON.parse(
|
||||
decodeURIComponent(
|
||||
(params.get('compositeQuery') as string).replace(/\+/g, ' '),
|
||||
),
|
||||
);
|
||||
// The real reader — the same entry point `useGetCompositeQueryParam` uses.
|
||||
const readCompositeQuery = (params: URLSearchParams): Query | null =>
|
||||
deserialize(params);
|
||||
|
||||
it.each([
|
||||
[PANEL_TYPES.TIME_SERIES, 'signoz/TimeSeriesPanel'],
|
||||
@@ -85,15 +82,14 @@ describe('newPanelRoute', () => {
|
||||
expect(readCompositeQuery(params)).toStrictEqual(query);
|
||||
});
|
||||
|
||||
// Regression: a bare `%`/`+` must survive the reader's double-decode.
|
||||
// Regression: a bare `%`/`+` must survive URL encode/decode intact.
|
||||
it('round-trips a filter expression containing % and + literals', () => {
|
||||
const expression = "severity_text ILIKE 'Inf%' AND path = 'a+b'";
|
||||
const queryWithLiterals = {
|
||||
id: 'q1',
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{ filter: { expression: "severity_text ILIKE 'Inf%' AND path = 'a+b'" } },
|
||||
],
|
||||
queryData: [{ filter: { expression } }],
|
||||
},
|
||||
} as unknown as Query;
|
||||
const link = buildExportPanelLink({
|
||||
@@ -102,7 +98,9 @@ describe('newPanelRoute', () => {
|
||||
query: queryWithLiterals,
|
||||
});
|
||||
const { params } = parseLink(link);
|
||||
expect(readCompositeQuery(params)).toStrictEqual(queryWithLiterals);
|
||||
expect(
|
||||
readCompositeQuery(params)?.builder.queryData[0].filter?.expression,
|
||||
).toBe(expression);
|
||||
});
|
||||
|
||||
it('returns null for a panel type with no V2 kind', () => {
|
||||
|
||||
@@ -4,8 +4,8 @@ import type {
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
@@ -50,11 +50,7 @@ function makePanel(queries: DashboardtypesQueryDTO[]): DashboardtypesPanelDTO {
|
||||
function makeUrlWrapper(
|
||||
query: Query,
|
||||
): ({ children }: { children: React.ReactNode }) => JSX.Element {
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
const params = serialize(query);
|
||||
return function UrlWrapper({
|
||||
children,
|
||||
}: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import {
|
||||
clearViewPanelHandoff,
|
||||
readViewPanelHandoff,
|
||||
@@ -56,11 +57,7 @@ describe('useSwitchToViewMode', () => {
|
||||
expect(target.pathname).toBe('/dashboard/dash-1');
|
||||
expect(target.searchParams.get('expandedWidgetId')).toBe('panel-1');
|
||||
expect(target.searchParams.get('graphType')).toBe(PANEL_TYPES.TIME_SERIES);
|
||||
expect(
|
||||
JSON.parse(
|
||||
decodeURIComponent(target.searchParams.get('compositeQuery') || ''),
|
||||
),
|
||||
).toStrictEqual(query);
|
||||
expect(deserialize(target.searchParams)).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('stashes the live draft spec in the sessionStorage handoff, not the URL', () => {
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import {
|
||||
applySerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { writeViewPanelHandoff } from '../../PanelsAndSectionsLayout/Panel/ViewPanelModal/viewPanelHandoffStore';
|
||||
@@ -44,10 +48,7 @@ export function useSwitchToViewMode({
|
||||
}
|
||||
params.set(QueryParams.expandedWidgetId, panelId);
|
||||
params.set(QueryParams.graphType, panelType);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
applySerializedParams(serialize(query), params);
|
||||
safeNavigate(
|
||||
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}?${params.toString()}`,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
@@ -45,11 +45,8 @@ export function parseNewPanelKind(
|
||||
|
||||
/**
|
||||
* New-panel editor link that exports an explorer query into a V2 dashboard. Carries the
|
||||
* raw `Query` as `compositeQuery` (conversion happens in the editor). `null` when the panel
|
||||
* type has no V2 kind, so the caller skips the export instead of landing on an unrelated kind.
|
||||
*
|
||||
* Double-encoded on purpose: `useGetCompositeQueryParam` decodes twice, so a single encode
|
||||
* would let a bare `%`/`+` (e.g. `ILIKE 'Inf%'`) break its second decode and drop the query.
|
||||
* raw `Query` (conversion happens in the editor). `null` when the panel type has no V2
|
||||
* kind, so the caller skips the export instead of landing on an unrelated kind.
|
||||
*/
|
||||
export function buildExportPanelLink({
|
||||
dashboardId,
|
||||
@@ -68,9 +65,7 @@ export function buildExportPanelLink({
|
||||
dashboardId,
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
return `${path}${newPanelSearch(kind)}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${encodeURIComponent(encodeURIComponent(JSON.stringify(query)))}`;
|
||||
return `${path}${newPanelSearch(kind)}&${serialize(query).toString()}`;
|
||||
}
|
||||
|
||||
/** Target section index for a new panel, or undefined when unset/invalid. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -228,8 +229,7 @@ describe('useViewPanel', () => {
|
||||
screen.getByTestId('search').textContent ?? '',
|
||||
);
|
||||
expect(search.get(QueryParams.expandedWidgetId)).toBe('B');
|
||||
const carried = search.get(QueryParams.compositeQuery);
|
||||
expect(panelOf(decodeURIComponent(carried as string))).toBe('B');
|
||||
expect(panelOf(JSON.stringify(deserialize(search)))).toBe('B');
|
||||
expect(search.get(QueryParams.graphType)).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import {
|
||||
applySerializedParams,
|
||||
clearSerializedParams,
|
||||
serialize,
|
||||
} from 'lib/compositeQuery/serializer';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { getPanelBuilderQuery } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelBuilderQuery';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -49,14 +54,14 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
// Copy before mutating: useUrlQuery returns a memoized instance.
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
// Drop leftover in-modal query/kind + the editor's handoff so a plain View opens
|
||||
// on the saved panel, not stale state the modal would otherwise hydrate from.
|
||||
clearSerializedParams(next);
|
||||
// Only a drilldown retargets the panel type.
|
||||
next.delete(QueryParams.graphType);
|
||||
clearViewPanelHandoff();
|
||||
const query = getPanelBuilderQuery(panel);
|
||||
next.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
applySerializedParams(serialize(query), next);
|
||||
// The provider applies the URL in an effect, a tick after the builder's fields have
|
||||
// mounted and read the query they keep. `resetQuery` — not `initQueryBuilderData`:
|
||||
// swapping one staged id for another re-anchors global time and refetches the grid.
|
||||
@@ -78,12 +83,10 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
// below carries this query's own id, and a staged query with a matching id
|
||||
// makes the provider skip the hydration that normalises legacy filter fields.
|
||||
resetQuery(query);
|
||||
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
|
||||
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
|
||||
next.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
// Clear first: an adapter can explode a query into many content-dependent keys,
|
||||
// so overwriting alone would leave the previous query's keys in the URL.
|
||||
clearSerializedParams(next);
|
||||
applySerializedParams(serialize(query), next);
|
||||
safeNavigate(`${pathname}?${next.toString()}`);
|
||||
},
|
||||
[pathname, safeNavigate, urlQuery, resetQuery],
|
||||
@@ -94,7 +97,7 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
next.delete(QueryParams.expandedWidgetId);
|
||||
// Drop the drilldown editor's URL state so it doesn't leak to the dashboard
|
||||
// (the in-modal query builder writes compositeQuery, V1 parity).
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
clearSerializedParams(next);
|
||||
next.delete(QueryParams.graphType);
|
||||
clearViewPanelHandoff();
|
||||
const search = next.toString();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
@@ -73,32 +74,25 @@ describe('buildCreateAlertUrl', () => {
|
||||
expect(params.get(QueryParams.source)).toBe('dashboards');
|
||||
});
|
||||
|
||||
it('encodes the translated query as the compositeQuery param', () => {
|
||||
it('serializes the translated query into the URL', () => {
|
||||
const params = parse(buildCreateAlertUrl(makePanel()));
|
||||
|
||||
const raw = params.get(QueryParams.compositeQuery);
|
||||
expect(raw).toBeTruthy();
|
||||
const decoded = JSON.parse(decodeURIComponent(raw as string));
|
||||
expect(decoded.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(decoded.id).toBe('q1');
|
||||
const decoded = deserialize(params);
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(decoded?.id).toBe('q1');
|
||||
});
|
||||
|
||||
it('carries the panel formatting unit onto the alert query when set', () => {
|
||||
const params = parse(buildCreateAlertUrl(makePanel({ unit: 'bytes' })));
|
||||
|
||||
const decoded = JSON.parse(
|
||||
decodeURIComponent(params.get(QueryParams.compositeQuery) as string),
|
||||
);
|
||||
expect(decoded.unit).toBe('bytes');
|
||||
expect(deserialize(params)?.unit).toBe('bytes');
|
||||
});
|
||||
|
||||
it('leaves the query unit unset when the panel has no formatting unit', () => {
|
||||
const params = parse(buildCreateAlertUrl(makePanel()));
|
||||
|
||||
const decoded = JSON.parse(
|
||||
decodeURIComponent(params.get(QueryParams.compositeQuery) as string),
|
||||
);
|
||||
expect(decoded.unit).toBeUndefined();
|
||||
expect(deserialize(params)?.unit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits the alert-condition params when the panel has no reduceTo or thresholds', () => {
|
||||
|
||||
@@ -167,9 +167,9 @@ describe('deriveAlertPrefill', () => {
|
||||
|
||||
it.each([
|
||||
['above', AlertThresholdOperator.IS_ABOVE],
|
||||
['above_or_equal', AlertThresholdOperator.IS_ABOVE_OR_EQUAL_TO],
|
||||
['above_or_equal', AlertThresholdOperator.IS_ABOVE],
|
||||
['below', AlertThresholdOperator.IS_BELOW],
|
||||
['below_or_equal', AlertThresholdOperator.IS_BELOW_OR_EQUAL_TO],
|
||||
['below_or_equal', AlertThresholdOperator.IS_BELOW],
|
||||
['equal', AlertThresholdOperator.IS_EQUAL_TO],
|
||||
['not_equal', AlertThresholdOperator.IS_NOT_EQUAL_TO],
|
||||
])('maps panel operator %s → %s', (op, expected) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user