Compare commits
10 Commits
ci/testsci
...
feat/gcp-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ca064ab0 | ||
|
|
79ac086698 | ||
|
|
40befa5c0f | ||
|
|
f815771df8 | ||
|
|
f6e16bf2fe | ||
|
|
7b887f5adf | ||
|
|
4f3fe7ec6e | ||
|
|
4f45b3a691 | ||
|
|
dc4b4c3d03 | ||
|
|
c5f550d88f |
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 && \
|
||||
|
||||
25
.github/workflows/integrationci.yaml
vendored
@@ -54,7 +54,6 @@ jobs:
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- rawexportdata
|
||||
- promqlconformance
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
@@ -110,30 +109,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
@@ -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
|
||||
|
||||
10
cmd/authz.go
@@ -93,13 +93,9 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
registry := coretypes.NewRegistry()
|
||||
|
||||
allowedResources := map[string]bool{
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
|
||||
}
|
||||
|
||||
allowedTypes := map[string]bool{}
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/zeustypes"
|
||||
)
|
||||
@@ -25,8 +25,8 @@ var meterConfigs = []metercollector.Config{
|
||||
Name: zeustypes.MeterLogSize,
|
||||
Unit: zeustypes.MeterUnitBytes,
|
||||
Aggregation: zeustypes.MeterAggregationSum,
|
||||
DBName: logstelemetryschema.DBName,
|
||||
TableName: logstelemetryschema.LogsV2LocalTableName,
|
||||
DBName: telemetrylogs.DBName,
|
||||
TableName: telemetrylogs.LogsV2LocalTableName,
|
||||
DefaultRetentionDays: retentiontypes.DefaultLogsRetentionDays,
|
||||
},
|
||||
},
|
||||
@@ -36,8 +36,8 @@ var meterConfigs = []metercollector.Config{
|
||||
Name: zeustypes.MeterSpanSize,
|
||||
Unit: zeustypes.MeterUnitBytes,
|
||||
Aggregation: zeustypes.MeterAggregationSum,
|
||||
DBName: tracestelemetryschema.DBName,
|
||||
TableName: tracestelemetryschema.SpanIndexV3LocalTableName,
|
||||
DBName: telemetrytraces.DBName,
|
||||
TableName: telemetrytraces.SpanIndexV3LocalTableName,
|
||||
DefaultRetentionDays: retentiontypes.DefaultTracesRetentionDays,
|
||||
},
|
||||
},
|
||||
@@ -47,8 +47,8 @@ var meterConfigs = []metercollector.Config{
|
||||
Name: zeustypes.MeterDatapointCount,
|
||||
Unit: zeustypes.MeterUnitCount,
|
||||
Aggregation: zeustypes.MeterAggregationSum,
|
||||
DBName: metricstelemetryschema.DBName,
|
||||
TableName: metricstelemetryschema.SamplesV4LocalTableName,
|
||||
DBName: telemetrymetrics.DBName,
|
||||
TableName: telemetrymetrics.SamplesV4LocalTableName,
|
||||
DefaultRetentionDays: retentiontypes.DefaultMetricsRetentionDays,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1496,9 +1496,6 @@ components:
|
||||
- redis
|
||||
- cloudsql_postgres
|
||||
- memorystore_redis
|
||||
- computeengine
|
||||
- gke
|
||||
- cloudstorage
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -7430,8 +7427,6 @@ components:
|
||||
- below
|
||||
- equal
|
||||
- not_equal
|
||||
- above_or_equal
|
||||
- below_or_equal
|
||||
- outside_bounds
|
||||
type: string
|
||||
RuletypesCumulativeSchedule:
|
||||
@@ -9939,9 +9934,14 @@ paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists the services metadata for the specified cloud
|
||||
provider, without any account context.
|
||||
provider
|
||||
operationId: ListServicesMetadata
|
||||
parameters:
|
||||
- in: query
|
||||
name: cloud_integration_id
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: cloud_provider
|
||||
required: true
|
||||
@@ -9991,10 +9991,14 @@ paths:
|
||||
/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets a service definition for the specified cloud
|
||||
provider, without any account context.
|
||||
description: This endpoint gets a service for the specified cloud provider
|
||||
operationId: GetService
|
||||
parameters:
|
||||
- in: query
|
||||
name: cloud_integration_id
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: cloud_provider
|
||||
required: true
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metertelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/licensetypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
@@ -153,7 +153,7 @@ func (provider *Provider) Collect(
|
||||
func buildOriginQuery(meterName string) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("toInt64(ifNull(min(unix_milli), 0))")
|
||||
sb.From(metertelemetryschema.DBName + "." + metertelemetryschema.SamplesTableName)
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.Where(sb.Equal("metric_name", meterName))
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func buildQuery(meterName string, segment *retentiontypes.RetentionPolicySegment
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(selects...)
|
||||
sb.From(metertelemetryschema.DBName + "." + metertelemetryschema.SamplesTableName)
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.Where(
|
||||
sb.Equal("metric_name", meterName),
|
||||
sb.GTE("unix_milli", segment.StartMs),
|
||||
|
||||
@@ -8,16 +8,16 @@ import (
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
)
|
||||
|
||||
var (
|
||||
reductionRulesTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.ReductionRulesTableName
|
||||
metadataTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.AttributesMetadataTableName
|
||||
bufferSeriesTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.TimeseriesV4BufferTableName
|
||||
reductionRulesTable = telemetrymetrics.DBName + "." + telemetrymetrics.ReductionRulesTableName
|
||||
metadataTable = telemetrymetrics.DBName + "." + telemetrymetrics.AttributesMetadataTableName
|
||||
bufferSeriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4BufferTableName
|
||||
)
|
||||
|
||||
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
|
||||
@@ -192,7 +192,7 @@ func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []stri
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "uniq(fingerprint)")
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
conds := []string{
|
||||
sb.In("metric_name", names...),
|
||||
sb.GE("unix_milli", startMs),
|
||||
@@ -229,8 +229,8 @@ func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []stri
|
||||
// reduced sample tables.
|
||||
func (c *clickhouse) reducedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
|
||||
out := make(map[string]uint64, len(metricNames))
|
||||
for _, table := range []string{metricstelemetryschema.SamplesV4ReducedLastTableName, metricstelemetryschema.SamplesV4ReducedSumTableName} {
|
||||
counts, err := c.reducedSeriesCountForTable(ctx, metricstelemetryschema.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
counts, err := c.reducedSeriesCountForTable(ctx, telemetrymetrics.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -300,9 +300,9 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
|
||||
direction = "DESC"
|
||||
}
|
||||
|
||||
ingestedTable := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName
|
||||
reducedLast := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4ReducedLastTableName
|
||||
reducedSum := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4ReducedSumTableName
|
||||
ingestedTable := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName
|
||||
reducedLast := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedLastTableName
|
||||
reducedSum := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedSumTableName
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("base.metric_name AS metric_name", "ifNull(i.cnt, 0) AS ingested", "ifNull(d.cnt, 0) AS reduced")
|
||||
@@ -352,16 +352,16 @@ func (c *clickhouse) SampleVolumeByMetric(ctx context.Context, metricNames []str
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
|
||||
ingested, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
last, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
last, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
sum, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -419,7 +419,7 @@ func (c *clickhouse) TotalVolume(ctx context.Context, startMs, endMs int64) (uin
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("uniq(fingerprint)", "count()")
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -470,7 +470,7 @@ func (c *clickhouse) SampleTimeseries(ctx context.Context, ruledMetrics []string
|
||||
func (c *clickhouse) totalSamplesByBucket(ctx context.Context, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "count()")
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
|
||||
sb.GroupBy("bucket")
|
||||
|
||||
@@ -485,7 +485,7 @@ func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNam
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "count()")
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
@@ -499,7 +499,7 @@ func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNam
|
||||
// reduced 60s rows are versioned by computed_at, so count distinct buckets.
|
||||
func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
out := make(map[int64]uint64)
|
||||
for _, table := range []string{metricstelemetryschema.SamplesV4ReducedLastTableName, metricstelemetryschema.SamplesV4ReducedSumTableName} {
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
@@ -507,7 +507,7 @@ func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNam
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "uniq(reduced_fingerprint, unix_milli)")
|
||||
sb.From(metricstelemetryschema.DBName + "." + table)
|
||||
sb.From(telemetrymetrics.DBName + "." + table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
|
||||
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 64 KiB |
@@ -1,136 +0,0 @@
|
||||
# AuthZ Guide
|
||||
|
||||
How to structure a page so it works with the permission system.
|
||||
|
||||
We are migrating from the `ADMIN | EDITOR | VIEWER` roles to per-action checks. Instead of granting `VIEWER` and
|
||||
exposing everything, a user can now be granted access to a single resource, eg: `Logs` only.
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Core rules](#core-rules)
|
||||
- [Page patterns](#page-patterns)
|
||||
- [What "blocked" means](#what-blocked-means)
|
||||
- [Migration checklist](#migration-checklist)
|
||||
- [Testing](#testing)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Check whether the resource your page represents is supported: see
|
||||
[permissions.type.ts](../src/lib/authz/hooks/useAuthZ/permissions.type.ts) for the current resources and their allowed
|
||||
verbs.
|
||||
|
||||
**If the resource is not listed there, skip authz for now**. The backend does not enforce it yet, so any frontend
|
||||
check would be decorative. Revisit once the resource is generated into that file (it is auto-generated from the
|
||||
backend).
|
||||
|
||||
## Core rules
|
||||
|
||||
These hold for every page. The per-pattern sections below only add to them.
|
||||
|
||||
1. **A page is always reachable, regardless of permission.** The intended action must be known before permission can
|
||||
be checked, so the route renders first and individual pieces are gated.
|
||||
2. **`update` requires both `read` and `update`.** A user who can write but cannot read the current value must not get
|
||||
an edit affordance.
|
||||
3. **`delete` is independent of `read`.** Delete controls stay visible for a user who holds `delete` but not `read`.
|
||||
4. **Never gate per row.** If a user can `list`, render every row. Check `read` only when the row is opened (drawer or
|
||||
detail route).
|
||||
5. **Gate the narrowest thing that works**, a button over a section, a section over a page.
|
||||
6. **A resource may be gated while a sub-resource is not.** A user without `read` on Service Accounts can still hold
|
||||
`create` on API Keys, so blocking the outer container would hide work they are allowed to do.
|
||||
7. **Verbs not covered here** (`attach`, `detach`, `assignee`) behave like `delete`: gate the control that triggers
|
||||
them, not the surrounding content.
|
||||
|
||||
## Page patterns
|
||||
|
||||
### List page
|
||||
|
||||

|
||||
|
||||
Without `list`, but with any of `read` / `create` / `update`, only the table is blocked:
|
||||
|
||||
- Title, description, search filters and action buttons stay visible.
|
||||
- Filters and any control that drives the table are non-interactive.
|
||||
- The create button stays enabled if the user holds `create`.
|
||||
|
||||
### Edit page
|
||||
|
||||

|
||||
|
||||
Without `read`:
|
||||
|
||||
- Block the content with `withAuthZContent`, not the whole route.
|
||||
- Keep delete visible (rule 3).
|
||||
- Keep update blocked (rule 2).
|
||||
|
||||
### Drawer
|
||||
|
||||

|
||||
|
||||
Without `read`:
|
||||
|
||||
- Block the drawer body, not the drawer itself.
|
||||
- Prefer routing that carries the resource ID in the URL, so delete stays reachable without `read`.
|
||||
- Keep update blocked (rule 2).
|
||||
- Watch for sub-resources the user may still be allowed to act on (rule 6).
|
||||
|
||||
### Create
|
||||
|
||||
Without `create`:
|
||||
|
||||
| Entry point | Gate with |
|
||||
| --------------------- | ------------------------------------------------------------- |
|
||||
| Button | `AuthZButton` |
|
||||
| Dedicated create page | `withAuthZPage` |
|
||||
| Create drawer opened directly (deep link) | `withAuthZContent` on the body + `AuthZButton` on footer actions |
|
||||
|
||||
### Delete
|
||||
|
||||
Gate the control with `AuthZButton`, or `AuthZTooltip` for a non-button trigger such as an icon or menu item.
|
||||
|
||||
### Quick filters
|
||||
|
||||

|
||||
|
||||
## What "blocked" means
|
||||
|
||||
Blocking is always a visible denial, never a silent removal. Use the components in
|
||||
[`lib/authz/components`](../src/lib/authz/components/README.md) rather than hand-rolling a check, they carry the
|
||||
denial message and the loading state.
|
||||
|
||||
| Scope | Component | Denied state |
|
||||
| --------------- | ----------------------------------------- | ------------------------------------------- |
|
||||
| Button | `AuthZButton` | Disabled + tooltip |
|
||||
| Any element | `AuthZTooltip` | Disabled child + tooltip |
|
||||
| Section | `withAuthZContent` / `AuthZGuardContent` | Inline `PermissionDeniedCallout` |
|
||||
| Page / route | `withAuthZPage` / `AuthZGuardPage` | `PermissionDeniedFullPage` |
|
||||
| Custom fallback | `withAuthZ` / `AuthZGuard` | Whatever you pass as `fallback` |
|
||||
|
||||
Prefer the HOC (`withAuthZ*`); reach for the JSX guard (`AuthZGuard*`) when the gate depends on conditional rendering
|
||||
and an HOC cannot express it. The components README has the full decision tree and how to build the `checks` array.
|
||||
|
||||
## Migration checklist
|
||||
|
||||
Use the existing components. Create a new one only if none fit.
|
||||
|
||||
- [ ] Can I apply a `withAuthZ*` variant directly, or must I extract the content into a component first?
|
||||
- If extraction is needed, declare the new content component in the same file to keep the diff small, then move it
|
||||
to its own file in a follow-up commit or PR.
|
||||
- [ ] Is the layout structured to respect the [core rules](#core-rules)?
|
||||
- If not, raise it in the frontend Slack channel before reshaping the page.
|
||||
- [ ] Am I gating a shared component rather than a page or section?
|
||||
- If so, it must stay functional with no permission. Example: the Query Builder works without field suggestions when
|
||||
the user cannot read them.
|
||||
|
||||
## Testing
|
||||
|
||||
### Devtool
|
||||
|
||||
Press `Cmd + K` (`Ctrl + K` on Windows/Linux) to open the shortcuts, search for `AuthZ`, and pick the first
|
||||
result. The devtool simulates granted and denied permissions across the UI.
|
||||
|
||||
Available only in local development, it is stripped from production builds.
|
||||
|
||||
### Unit tests
|
||||
|
||||
[`lib/authz/utils/README.md`](../src/lib/authz/utils/README.md) covers the `*.authz.test.tsx` naming convention, the
|
||||
MSW handlers (`setupAuthzAdmin`, `setupAuthzDenyAll`, `setupAuthzDeny`, `setupAuthzAllow`,
|
||||
`setupAuthzGrantByPrefix`), and how to test the loading state.
|
||||
@@ -14,10 +14,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import {
|
||||
routePermission,
|
||||
routeWithInitialAuthZSupport,
|
||||
} from 'utils/permission';
|
||||
import { routePermission } from 'utils/permission';
|
||||
|
||||
import routes, {
|
||||
LIST_LICENSES,
|
||||
@@ -45,6 +42,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
|
||||
|
||||
const mapRoutes = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -228,16 +226,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
if (isPrivate) {
|
||||
if (isLoggedInState) {
|
||||
const route = routePermission[key];
|
||||
const hasInitialAuthZSupport = Object.hasOwn(
|
||||
routeWithInitialAuthZSupport,
|
||||
key,
|
||||
);
|
||||
|
||||
if (
|
||||
route &&
|
||||
route.find((e) => e === user.role) === undefined &&
|
||||
hasInitialAuthZSupport === false
|
||||
) {
|
||||
if (route && route.find((e) => e === user.role) === undefined) {
|
||||
return <Redirect to={ROUTES.UN_AUTHORIZED} />;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { routeWithInitialAuthZSupport } from 'utils/permission';
|
||||
|
||||
import PrivateRoute from '../Private';
|
||||
|
||||
@@ -179,7 +178,6 @@ function createMockAppContext(
|
||||
isFetchingHosts: false,
|
||||
isFetchingFeatureFlags: false,
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
userFetchError: null,
|
||||
activeLicenseFetchError: null,
|
||||
hostsFetchError: null,
|
||||
@@ -200,39 +198,24 @@ function createMockAppContext(
|
||||
};
|
||||
}
|
||||
|
||||
// Roles that no authz-aware route grants through the legacy routePermission table
|
||||
const DENIED_ROLES: ROLES[] = [
|
||||
USER_ROLES.ANONYMOUS as ROLES,
|
||||
USER_ROLES.AUTHOR as ROLES,
|
||||
];
|
||||
|
||||
const PERMITTED_ROLES: ROLES[] = [
|
||||
USER_ROLES.ADMIN as ROLES,
|
||||
USER_ROLES.EDITOR as ROLES,
|
||||
USER_ROLES.VIEWER as ROLES,
|
||||
];
|
||||
|
||||
interface AuthzRouteCase {
|
||||
path: string;
|
||||
deniedRoles: ROLES[];
|
||||
}
|
||||
|
||||
interface RenderPrivateRouteOptions {
|
||||
initialRoute?: string;
|
||||
appContext?: Partial<IAppContext>;
|
||||
isCloudUser?: boolean;
|
||||
}
|
||||
|
||||
function buildPrivateRouteTree(
|
||||
options: RenderPrivateRouteOptions,
|
||||
): ReactElement {
|
||||
const { initialRoute = ROUTES.HOME, appContext = {} } = options;
|
||||
function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
|
||||
const {
|
||||
initialRoute = ROUTES.HOME,
|
||||
appContext = {},
|
||||
isCloudUser = true,
|
||||
} = options;
|
||||
|
||||
const contextValue = createMockAppContext({
|
||||
...appContext,
|
||||
});
|
||||
mockIsCloudUser = isCloudUser;
|
||||
|
||||
return (
|
||||
const contextValue = createMockAppContext(appContext);
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<AppContext.Provider value={contextValue}>
|
||||
@@ -246,15 +229,10 @@ function buildPrivateRouteTree(
|
||||
</PrivateRoute>
|
||||
</AppContext.Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
|
||||
mockIsCloudUser = options.isCloudUser ?? true;
|
||||
render(buildPrivateRouteTree(options));
|
||||
}
|
||||
|
||||
// Generic assertion helpers for navigation behavior
|
||||
// Using location-based assertions since Private.tsx now uses Redirect component
|
||||
|
||||
@@ -1454,25 +1432,6 @@ describe('PrivateRoute', () => {
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
});
|
||||
|
||||
it.each([...PERMITTED_ROLES, ...DENIED_ROLES])(
|
||||
'should render the unauthorized page for a %s instead of bouncing off it',
|
||||
(role) => {
|
||||
// routePermission.UN_AUTHORIZED must grant every role: a role denied here
|
||||
// is redirected to /un-authorized and then redirected away from it again,
|
||||
// which loops until React bails out with a blank screen.
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.UN_AUTHORIZED,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.UN_AUTHORIZED);
|
||||
assertRendersChildren();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
@@ -1518,174 +1477,6 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthZ Support (routeWithInitialAuthZSupport)', () => {
|
||||
// Routes in routeWithInitialAuthZSupport always bypass legacy role check
|
||||
const AUTHZ_ROUTE_CASES: Record<
|
||||
keyof typeof routeWithInitialAuthZSupport,
|
||||
AuthzRouteCase
|
||||
> = {
|
||||
// Everything under /settings resolves to the non-exact SETTINGS route
|
||||
SETTINGS: { path: ROUTES.SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
MY_SETTINGS: { path: ROUTES.MY_SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
ROLES_SETTINGS: { path: ROUTES.ROLES_SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
ROLE_CREATE: { path: ROUTES.ROLE_CREATE, deniedRoles: DENIED_ROLES },
|
||||
ROLE_DETAILS: {
|
||||
path: ROUTES.ROLE_DETAILS.replace(':roleId', 'role-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
ROLE_EDIT: {
|
||||
path: ROUTES.ROLE_EDIT.replace(':roleId', 'role-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
SERVICE_ACCOUNTS_SETTINGS: {
|
||||
path: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
TRACES_EXPLORER: { path: ROUTES.TRACES_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
TRACE: { path: ROUTES.TRACE, deniedRoles: DENIED_ROLES },
|
||||
TRACE_DETAIL: {
|
||||
path: ROUTES.TRACE_DETAIL.replace(':id', 'trace-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
TRACE_DETAIL_OLD: {
|
||||
path: ROUTES.TRACE_DETAIL_OLD.replace(':id', 'trace-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
// LOGS and LOGS_EXPLORER share a path - matchPath resolves it to whichever
|
||||
// route definition comes last, and both keys are authz-aware either way.
|
||||
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },
|
||||
LOGS_EXPLORER: { path: ROUTES.LOGS_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
LIVE_LOGS: { path: ROUTES.LIVE_LOGS, deniedRoles: DENIED_ROLES },
|
||||
OLD_LOGS_EXPLORER: {
|
||||
path: ROUTES.OLD_LOGS_EXPLORER,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
METRICS_EXPLORER: {
|
||||
path: ROUTES.METRICS_EXPLORER,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
METRICS_EXPLORER_EXPLORER: {
|
||||
path: ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
METRICS_EXPLORER_VOLUME_CONTROL: {
|
||||
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
METER: { path: ROUTES.METER, deniedRoles: DENIED_ROLES },
|
||||
METER_EXPLORER: { path: ROUTES.METER_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
// SUPPORT already grants ANONYMOUS in routePermission, so only AUTHOR
|
||||
// exercises the redirect branch here.
|
||||
SUPPORT: {
|
||||
path: ROUTES.SUPPORT,
|
||||
deniedRoles: [USER_ROLES.AUTHOR as ROLES],
|
||||
},
|
||||
};
|
||||
|
||||
const authzRouteRolePairs: [string, string, ROLES][] = Object.entries(
|
||||
AUTHZ_ROUTE_CASES,
|
||||
).flatMap(([name, { path, deniedRoles }]) =>
|
||||
deniedRoles.map((role): [string, string, ROLES] => [name, path, role]),
|
||||
);
|
||||
|
||||
// Routes with no authz check - legacy role check still applies
|
||||
const NON_AUTHZ_ROUTE_CASES: [string, string, ROLES][] = [
|
||||
['ALERTS_NEW', ROUTES.ALERTS_NEW, USER_ROLES.VIEWER as ROLES],
|
||||
['LIST_LICENSES', ROUTES.LIST_LICENSES, USER_ROLES.EDITOR as ROLES],
|
||||
['ONBOARDING', ROUTES.ONBOARDING, USER_ROLES.VIEWER as ROLES],
|
||||
['APPLICATION', ROUTES.APPLICATION, USER_ROLES.ANONYMOUS as ROLES],
|
||||
[
|
||||
'METRICS_EXPLORER_VIEWS',
|
||||
ROUTES.METRICS_EXPLORER_VIEWS,
|
||||
USER_ROLES.ANONYMOUS as ROLES,
|
||||
],
|
||||
];
|
||||
|
||||
it.each(authzRouteRolePairs)(
|
||||
'should not redirect %s (%s) for role %s - authz routes bypass legacy role check',
|
||||
(_name, path, role) => {
|
||||
// Routes in routeWithInitialAuthZSupport always bypass the legacy role check.
|
||||
// Authorization is handled by downstream components via fine-grained authz.
|
||||
renderPrivateRoute({
|
||||
initialRoute: path,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(path);
|
||||
assertRendersChildren();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([...PERMITTED_ROLES, ...DENIED_ROLES])(
|
||||
'should not redirect a %s from an authz-aware route',
|
||||
(role) => {
|
||||
// All roles pass through - authorization handled downstream
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.LOGS_EXPLORER,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.LOGS_EXPLORER);
|
||||
assertRendersChildren();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(NON_AUTHZ_ROUTE_CASES)(
|
||||
'should redirect %s (%s) for role %s - non-authz routes use legacy role check',
|
||||
async (_name, path, role) => {
|
||||
// Routes NOT in routeWithInitialAuthZSupport still use legacy role check
|
||||
renderPrivateRoute({
|
||||
initialRoute: path,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(authzRouteRolePairs)(
|
||||
'should still redirect unauthenticated users away from %s (%s) with role %s',
|
||||
async (_name, path, role) => {
|
||||
// The authz bypass only relaxes the role check, never the login check.
|
||||
renderPrivateRoute({
|
||||
initialRoute: path,
|
||||
appContext: {
|
||||
isLoggedIn: false,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.LOGIN);
|
||||
},
|
||||
);
|
||||
|
||||
it('should still redirect to workspace locked from an authz-aware route', async () => {
|
||||
// Workspace guards run before the role check and must not be bypassed.
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.LOGS_EXPLORER,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
isFetchingActiveLicense: false,
|
||||
activeLicense: createMockLicense({ platform: LicensePlatform.CLOUD }),
|
||||
trialInfo: createMockTrialInfo({ workSpaceBlock: true }),
|
||||
user: createMockUser({ role: USER_ROLES.VIEWER as ROLES }),
|
||||
},
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Old channel route redirects', () => {
|
||||
it.each([
|
||||
['/settings/channels', '/alerts', 'tab=Channels'],
|
||||
|
||||
@@ -2,29 +2,6 @@ import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
// The wire shape these handlers can actually rely on. The generated
|
||||
// RenderErrorResponseDTO marks code/message/url/errors as required, but the
|
||||
// server omits any of them even on valid errors (e.g. a 400 with just a
|
||||
// message), so a present `error` object is all the guard can guarantee.
|
||||
type ErrorEnvelope = {
|
||||
error: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
url?: string;
|
||||
errors?: { message?: string }[];
|
||||
};
|
||||
};
|
||||
|
||||
function isErrorEnvelope(data: unknown): data is ErrorEnvelope {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'error' in data &&
|
||||
typeof (data as ErrorEnvelope).error === 'object' &&
|
||||
(data as ErrorEnvelope).error !== null
|
||||
);
|
||||
}
|
||||
|
||||
// @deprecated Use convertToApiError instead
|
||||
export function ErrorResponseHandlerForGeneratedAPIs(
|
||||
error: AxiosError<RenderErrorResponseDTO>,
|
||||
@@ -33,29 +10,15 @@ export function ErrorResponseHandlerForGeneratedAPIs(
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (response) {
|
||||
// The body isn't guaranteed to be an error envelope — e.g. a gateway 5xx
|
||||
// with an HTML/empty body during a deploy. Verify the shape before reading
|
||||
// it; otherwise synthesize a consistent error from the status.
|
||||
const data: unknown = response.data;
|
||||
if (isErrorEnvelope(data)) {
|
||||
const { code, message, url, errors } = data.error;
|
||||
throw new APIError({
|
||||
httpStatusCode: response.status || 500,
|
||||
error: {
|
||||
code: code ?? '',
|
||||
message: message ?? '',
|
||||
url: url ?? '',
|
||||
errors: (errors ?? []).map((e) => ({ message: e.message ?? '' })),
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new APIError({
|
||||
httpStatusCode: response.status || 500,
|
||||
error: {
|
||||
code: 'UPSTREAM_UNAVAILABLE',
|
||||
message: error.message || 'Something went wrong',
|
||||
url: '',
|
||||
errors: [],
|
||||
code: response.data.error.code,
|
||||
message: response.data.error.message,
|
||||
url: response.data.error.url ?? '',
|
||||
errors: (response.data.error.errors ?? []).map((e) => ({
|
||||
message: e.message ?? '',
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -99,7 +62,9 @@ export function convertToApiError(
|
||||
return new APIError({
|
||||
httpStatusCode: response?.status || error.status || 500,
|
||||
error: {
|
||||
code: errorData?.code || 'UPSTREAM_UNAVAILABLE',
|
||||
code:
|
||||
errorData?.code ||
|
||||
String(response?.status || error.code || 'unknown_error'),
|
||||
message:
|
||||
errorData?.message ||
|
||||
response?.statusText ||
|
||||
|
||||
@@ -2,38 +2,19 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
function isErrorV2Resp(data: unknown): data is ErrorV2Resp {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'error' in data &&
|
||||
typeof (data as ErrorV2Resp).error?.code === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
// reference - https://axios-http.com/docs/handling_errors
|
||||
export function ErrorResponseHandlerV2(error: AxiosError<ErrorV2Resp>): never {
|
||||
const { response, request } = error;
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (response) {
|
||||
// response.data isn't guaranteed to be a V2 envelope (e.g. a gateway 5xx
|
||||
// with an HTML/empty body during a deploy), so verify the shape first.
|
||||
const data: unknown = response.data;
|
||||
if (isErrorV2Resp(data)) {
|
||||
const { code, message, url, errors } = data.error;
|
||||
throw new APIError({
|
||||
httpStatusCode: response.status || 500,
|
||||
error: { code, message, url, errors },
|
||||
});
|
||||
}
|
||||
throw new APIError({
|
||||
httpStatusCode: response.status || 500,
|
||||
error: {
|
||||
code: 'UPSTREAM_UNAVAILABLE',
|
||||
message: error.message || 'Something went wrong',
|
||||
url: '',
|
||||
errors: [],
|
||||
code: response.data.error.code,
|
||||
message: response.data.error.message,
|
||||
url: response.data.error.url,
|
||||
errors: response.data.error.errors,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
function asAxiosError(partial: Partial<AxiosError>): AxiosError<ErrorV2Resp> {
|
||||
return partial as AxiosError<ErrorV2Resp>;
|
||||
}
|
||||
|
||||
// ErrorResponseHandlerV2 always throws — capture the APIError so assertions stay
|
||||
// unconditional.
|
||||
function runHandler(error: AxiosError<ErrorV2Resp>): APIError {
|
||||
try {
|
||||
ErrorResponseHandlerV2(error);
|
||||
} catch (thrown) {
|
||||
return thrown as APIError;
|
||||
}
|
||||
throw new Error('expected ErrorResponseHandlerV2 to throw');
|
||||
}
|
||||
|
||||
type ExpectedError = {
|
||||
httpStatusCode: number;
|
||||
code: string;
|
||||
message: string;
|
||||
errors: { message: string }[];
|
||||
};
|
||||
|
||||
// One row per response shape the handler must normalize. New shapes (with
|
||||
// different bodies) can be added here without a new test block.
|
||||
const cases: {
|
||||
name: string;
|
||||
error: AxiosError<ErrorV2Resp>;
|
||||
expected: ExpectedError;
|
||||
}[] = [
|
||||
{
|
||||
name: 'well-formed V2 error envelope',
|
||||
error: asAxiosError({
|
||||
message: 'Request failed with status code 400',
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
error: {
|
||||
code: 'bad_request',
|
||||
message: 'Invalid dashboard payload',
|
||||
url: 'https://signoz.io/docs',
|
||||
errors: [{ message: 'name is required' }, { message: 'name too long' }],
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'],
|
||||
}),
|
||||
expected: {
|
||||
httpStatusCode: 400,
|
||||
code: 'bad_request',
|
||||
message: 'Invalid dashboard payload',
|
||||
errors: [{ message: 'name is required' }, { message: 'name too long' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Regression: during a deployment the gateway returns a 5xx with a
|
||||
// non-envelope body. Reading response.data.error.code used to throw a
|
||||
// TypeError from inside the handler itself. See engineering-pod#5760.
|
||||
name: '5xx with a non-envelope HTML body',
|
||||
error: asAxiosError({
|
||||
message: 'Request failed with status code 503',
|
||||
response: {
|
||||
status: 503,
|
||||
data: '<html><body>503 Service Temporarily Unavailable</body></html>',
|
||||
} as AxiosError['response'],
|
||||
}),
|
||||
expected: {
|
||||
httpStatusCode: 503,
|
||||
code: 'UPSTREAM_UNAVAILABLE',
|
||||
message: 'Request failed with status code 503',
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '5xx with an empty body',
|
||||
error: asAxiosError({
|
||||
message: 'Request failed with status code 502',
|
||||
response: {
|
||||
status: 502,
|
||||
data: undefined,
|
||||
} as AxiosError['response'],
|
||||
}),
|
||||
expected: {
|
||||
httpStatusCode: 502,
|
||||
code: 'UPSTREAM_UNAVAILABLE',
|
||||
message: 'Request failed with status code 502',
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'no response received (network error)',
|
||||
error: asAxiosError({
|
||||
message: 'Network Error',
|
||||
code: 'ERR_NETWORK',
|
||||
name: 'AxiosError',
|
||||
request: {},
|
||||
}),
|
||||
expected: {
|
||||
httpStatusCode: 500,
|
||||
code: 'ERR_NETWORK',
|
||||
message: 'Network Error',
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('ErrorResponseHandlerV2', () => {
|
||||
it.each(cases)(
|
||||
'normalizes $name into a consistent APIError',
|
||||
({ error, expected }) => {
|
||||
const apiError = runHandler(error);
|
||||
|
||||
expect(apiError).toBeInstanceOf(APIError);
|
||||
expect(apiError.getHttpStatusCode()).toBe(expected.httpStatusCode);
|
||||
expect(apiError.getErrorCode()).toBe(expected.code);
|
||||
expect(apiError.getErrorMessage()).toBe(expected.message);
|
||||
// The sub-error messages feed several parts of the UI, so assert them.
|
||||
expect(apiError.getErrorDetails().error.errors).toStrictEqual(
|
||||
expected.errors,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
28
frontend/src/api/alerts/ruleStats.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleStatsPayload } from 'types/api/alerts/def';
|
||||
import { RuleStatsProps } from 'types/api/alerts/ruleStats';
|
||||
|
||||
const ruleStats = async (
|
||||
props: RuleStatsProps,
|
||||
): Promise<SuccessResponse<AlertRuleStatsPayload> | ErrorResponse> => {
|
||||
try {
|
||||
const response = await axios.post(`/rules/${props.id}/history/stats`, {
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default ruleStats;
|
||||
33
frontend/src/api/alerts/timelineGraph.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleTimelineGraphResponsePayload } from 'types/api/alerts/def';
|
||||
import { GetTimelineGraphRequestProps } from 'types/api/alerts/timelineGraph';
|
||||
|
||||
const timelineGraph = async (
|
||||
props: GetTimelineGraphRequestProps,
|
||||
): Promise<
|
||||
SuccessResponse<AlertRuleTimelineGraphResponsePayload> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/rules/${props.id}/history/overall_status`,
|
||||
{
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default timelineGraph;
|
||||
36
frontend/src/api/alerts/timelineTable.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleTimelineTableResponsePayload } from 'types/api/alerts/def';
|
||||
import { GetTimelineTableRequestProps } from 'types/api/alerts/timelineTable';
|
||||
|
||||
const timelineTable = async (
|
||||
props: GetTimelineTableRequestProps,
|
||||
): Promise<
|
||||
SuccessResponse<AlertRuleTimelineTableResponsePayload> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.post(`/rules/${props.id}/history/timeline`, {
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
offset: props.offset,
|
||||
limit: props.limit,
|
||||
order: props.order,
|
||||
state: props.state,
|
||||
// TODO(shaheer): implement filters
|
||||
filters: props.filters,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default timelineTable;
|
||||
33
frontend/src/api/alerts/topContributors.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleTopContributorsPayload } from 'types/api/alerts/def';
|
||||
import { TopContributorsProps } from 'types/api/alerts/topContributors';
|
||||
|
||||
const topContributors = async (
|
||||
props: TopContributorsProps,
|
||||
): Promise<
|
||||
SuccessResponse<AlertRuleTopContributorsPayload> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/rules/${props.id}/history/top_contributors`,
|
||||
{
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default topContributors;
|
||||
@@ -36,12 +36,14 @@ import type {
|
||||
GetConnectionCredentials200,
|
||||
GetConnectionCredentialsPathParameters,
|
||||
GetService200,
|
||||
GetServiceParams,
|
||||
GetServicePathParameters,
|
||||
ListAccountServicesMetadata200,
|
||||
ListAccountServicesMetadataPathParameters,
|
||||
ListAccounts200,
|
||||
ListAccountsPathParameters,
|
||||
ListServicesMetadata200,
|
||||
ListServicesMetadataParams,
|
||||
ListServicesMetadataPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateAccountPathParameters,
|
||||
@@ -1160,24 +1162,30 @@ export const invalidateGetConnectionCredentials = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists the services metadata for the specified cloud provider, without any account context.
|
||||
* This endpoint lists the services metadata for the specified cloud provider
|
||||
* @summary List services metadata
|
||||
*/
|
||||
export const listServicesMetadata = (
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListServicesMetadata200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/services`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListServicesMetadataQueryKey = ({
|
||||
cloudProvider,
|
||||
}: ListServicesMetadataPathParameters) => {
|
||||
return [`/api/v1/cloud_integrations/${cloudProvider}/services`] as const;
|
||||
export const getListServicesMetadataQueryKey = (
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/services`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getListServicesMetadataQueryOptions = <
|
||||
@@ -1185,6 +1193,7 @@ export const getListServicesMetadataQueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>,
|
||||
@@ -1196,11 +1205,12 @@ export const getListServicesMetadataQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListServicesMetadataQueryKey({ cloudProvider });
|
||||
queryOptions?.queryKey ??
|
||||
getListServicesMetadataQueryKey({ cloudProvider }, params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>
|
||||
> = ({ signal }) => listServicesMetadata({ cloudProvider }, signal);
|
||||
> = ({ signal }) => listServicesMetadata({ cloudProvider }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1228,6 +1238,7 @@ export function useListServicesMetadata<
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>,
|
||||
@@ -1238,6 +1249,7 @@ export function useListServicesMetadata<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListServicesMetadataQueryOptions(
|
||||
{ cloudProvider },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1254,10 +1266,11 @@ export function useListServicesMetadata<
|
||||
export const invalidateListServicesMetadata = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }) },
|
||||
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }, params) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1265,26 +1278,29 @@ export const invalidateListServicesMetadata = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint gets a service definition for the specified cloud provider, without any account context.
|
||||
* This endpoint gets a service for the specified cloud provider
|
||||
* @summary Get service
|
||||
*/
|
||||
export const getService = (
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetService200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetServiceQueryKey = ({
|
||||
cloudProvider,
|
||||
serviceId,
|
||||
}: GetServicePathParameters) => {
|
||||
export const getGetServiceQueryKey = (
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
@@ -1293,6 +1309,7 @@ export const getGetServiceQueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getService>>,
|
||||
@@ -1304,11 +1321,12 @@ export const getGetServiceQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetServiceQueryKey({ cloudProvider, serviceId });
|
||||
queryOptions?.queryKey ??
|
||||
getGetServiceQueryKey({ cloudProvider, serviceId }, params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getService>>> = ({
|
||||
signal,
|
||||
}) => getService({ cloudProvider, serviceId }, signal);
|
||||
}) => getService({ cloudProvider, serviceId }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1334,6 +1352,7 @@ export function useGetService<
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getService>>,
|
||||
@@ -1344,6 +1363,7 @@ export function useGetService<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetServiceQueryOptions(
|
||||
{ cloudProvider, serviceId },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1360,10 +1380,11 @@ export function useGetService<
|
||||
export const invalidateGetService = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }) },
|
||||
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }, params) },
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
@@ -2815,9 +2815,6 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
redis = 'redis',
|
||||
cloudsql_postgres = 'cloudsql_postgres',
|
||||
memorystore_redis = 'memorystore_redis',
|
||||
computeengine = 'computeengine',
|
||||
gke = 'gke',
|
||||
cloudstorage = 'cloudstorage',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -8479,8 +8476,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 {
|
||||
@@ -10209,6 +10204,14 @@ export type GetConnectionCredentials200 = {
|
||||
export type ListServicesMetadataPathParameters = {
|
||||
cloudProvider: string;
|
||||
};
|
||||
export type ListServicesMetadataParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
cloud_integration_id?: string;
|
||||
};
|
||||
|
||||
export type ListServicesMetadata200 = {
|
||||
data: CloudintegrationtypesGettableServicesMetadataDTO;
|
||||
/**
|
||||
@@ -10221,6 +10224,14 @@ export type GetServicePathParameters = {
|
||||
cloudProvider: string;
|
||||
serviceId: string;
|
||||
};
|
||||
export type GetServiceParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
cloud_integration_id?: string;
|
||||
};
|
||||
|
||||
export type GetService200 = {
|
||||
data: CloudintegrationtypesServiceDTO;
|
||||
/**
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}.cls-4{fill:#fff;}</style></defs><title>Icon_24px_CloudStorage_Color</title><g data-name="Product Icons"><rect class="cls-1" x="2" y="4" width="20" height="7"/><rect class="cls-2" x="20" y="4" width="2" height="7"/><polygon class="cls-3" points="22 4 20 4 20 11 22 4"/><rect class="cls-2" x="2" y="4" width="2" height="7"/><rect class="cls-4" x="6" y="7" width="6" height="1"/><rect class="cls-4" x="15" y="6" width="3" height="3" rx="1.5"/><rect class="cls-1" x="2" y="13" width="20" height="7"/><rect class="cls-2" x="20" y="13" width="2" height="7"/><polygon class="cls-3" points="22 13 20 13 20 20 22 13"/><rect class="cls-2" x="2" y="13" width="2" height="7"/><rect class="cls-4" x="6" y="16" width="6" height="1"/><rect class="cls-4" x="15" y="15" width="3" height="3" rx="1.5"/></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1200" height="800" viewBox="-19.2 -28.483 166.401 170.898"><g transform="translate(0 -7.034)"><linearGradient id="a" x1="64" x2="64" y1="7.034" y2="120.789" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4387fd"/><stop offset="1" stop-color="#4683ea"/></linearGradient><path fill="url(#a)" d="M27.79 115.217 1.54 69.749a11.5 11.5 0 0 1 0-11.499l26.25-45.467a11.5 11.5 0 0 1 9.96-5.75h52.5a11.5 11.5 0 0 1 9.959 5.75l26.25 45.467a11.5 11.5 0 0 1 0 11.5l-26.25 45.466a11.5 11.5 0 0 1-9.959 5.75h-52.5a11.5 11.5 0 0 1-9.96-5.75z"/></g><g transform="translate(0 -7.034)"><defs><path id="b" d="M27.791 115.217 1.541 69.749a11.5 11.5 0 0 1 0-11.499l26.25-45.467a11.5 11.5 0 0 1 9.959-5.75h52.5a11.5 11.5 0 0 1 9.96 5.75l26.25 45.467a11.5 11.5 0 0 1 0 11.5l-26.25 45.466a11.5 11.5 0 0 1-9.96 5.75h-52.5a11.5 11.5 0 0 1-9.959-5.75z"/></defs><clipPath id="c"><use xlink:href="#b" width="100%" height="100%" overflow="visible"/></clipPath><path d="m49.313 53.875-7.01 6.99 5.957 5.958-5.898 10.476 44.635 44.636 10.816.002L118.936 84 85.489 50.55z" clip-path="url(#c)" opacity=".07"/></g><path fill="#fff" d="M84.7 43.236H43.264c-.667 0-1.212.546-1.212 1.214v8.566c0 .666.546 1.212 1.212 1.212H84.7c.667 0 1.213-.546 1.213-1.212v-8.568c0-.666-.545-1.213-1.212-1.213m-6.416 7.976a2.484 2.484 0 0 1-2.477-2.48 2.475 2.475 0 0 1 2.477-2.477c1.37 0 2.48 1.103 2.48 2.477a2.48 2.48 0 0 1-2.48 2.48m6.415 8.491-41.436.002c-.667 0-1.212.546-1.212 1.214v8.565c0 .666.546 1.213 1.212 1.213H84.7c.667 0 1.213-.547 1.213-1.213v-8.567c0-.666-.545-1.214-1.212-1.214m-6.416 7.976a2.483 2.483 0 0 1-2.477-2.48 2.475 2.475 0 0 1 2.477-2.477 2.48 2.48 0 1 1 0 4.956"/></svg>
|
||||
|
Before Width: | Height: | Size: 958 B After Width: | Height: | Size: 1.7 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#4285f4;}.cls-1,.cls-2,.cls-4{fill-rule:evenodd;}.cls-2{fill:#669df6;}.cls-3,.cls-4{fill:#aecbfa;}</style></defs><title>Icon_24px_K8Engine_Color</title><g data-name="Product Icons"><g ><polygon class="cls-1" points="14.68 13.06 19.23 15.69 19.23 16.68 14.29 13.83 14.68 13.06"/><polygon class="cls-2" points="9.98 13.65 4.77 16.66 4.45 15.86 9.53 12.92 9.98 13.65"/><rect class="cls-3" x="11.55" y="3.29" width="0.86" height="5.78"/><path class="cls-4" d="M3.25,7V17L12,22l8.74-5V7L12,2Zm15.63,8.89L12,19.78,5.12,15.89V8.11L12,4.22l6.87,3.89v7.78Z"/><polygon class="cls-4" points="11.98 11.5 15.96 9.21 11.98 6.91 8.01 9.21 11.98 11.5"/><polygon class="cls-2" points="11.52 12.3 7.66 10.01 7.66 14.6 11.52 16.89 11.52 12.3"/><polygon class="cls-1" points="12.48 12.3 12.48 16.89 16.34 14.6 16.34 10.01 12.48 12.3"/></g></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path d="M19.73 6.56a1.73 1.73 0 0 0-1.68 1.68 1.83 1.83 0 0 0 .89 1.48v6.9l-5.16 3.06.8 1.28 5.55-3.25a.84.84 0 0 0 .4-.69v-7.3a1.64 1.64 0 0 0 .89-1.48 1.61 1.61 0 0 0-1.69-1.68" style="fill:#aecbfa"/><path d="m18 5.48-5.61-3.16a1.18 1.18 0 0 0-.79 0L5.25 6a1.72 1.72 0 0 0-2.68 1.35A1.73 1.73 0 0 0 4.26 9a1.73 1.73 0 0 0 1.68-1.65L12 3.9l5.15 3ZM11.2 18.5a1.57 1.57 0 0 0-.89.29l-5.16-3V9.92H3.56v6.31a.84.84 0 0 0 .4.69L9.52 20v.09a1.69 1.69 0 0 0 3.37 0 1.65 1.65 0 0 0-1.69-1.59" data-name="Path" style="fill:#aecbfa"/><path d="M16.96 8.63 12.1 5.78 7.13 8.63l4.97 2.77z" data-name="Path" style="fill:#669df6"/><path d="M12.1 12.38 6.84 9.32v2.47l5.26 2.96zM12.1 15.73l-5.26-3.05v2.07l5.26 3.06z" style="fill:#669df6"/><path d="M12.09 12.38v2.37l5.26-3.06V9.33Zm4.32-.94a.38.38 0 1 1 0-.76.38.38 0 0 1 0 .76M12.09 15.73v2.07l5.26-3.05v-2.07Zm4.32-1.07a.38.38 0 1 1 0-.76.38.38 0 0 1 0 .76" style="fill:#4285f4"/></svg>
|
||||
|
Before Width: | Height: | Size: 941 B After Width: | Height: | Size: 988 B |
1
frontend/src/assets/Logos/gcp.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 128 128"><path fill="#ea4535" d="M80.6 40.3h.4l-.2-.2 14-14v-.3c-11.8-10.4-28.1-14-43.2-9.5C36.5 20.8 24.9 32.8 20.7 48c.2-.1.5-.2.8-.2 5.2-3.4 11.4-5.4 17.9-5.4 2.2 0 4.3.2 6.4.6.1-.1.2-.1.3-.1 9-9.9 24.2-11.1 34.6-2.6h-.1z"/><path fill="#557ebf" d="M108.1 47.8c-2.3-8.5-7.1-16.2-13.8-22.1L80 39.9c6 4.9 9.5 12.3 9.3 20v2.5c16.9 0 16.9 25.2 0 25.2H63.9v20h-.1l.1.2h25.4c14.6.1 27.5-9.3 31.8-23.1 4.3-13.8-1-28.8-13-36.9z"/><path fill="#36a852" d="M39 107.9h26.3V87.7H39c-1.9 0-3.7-.4-5.4-1.1l-15.2 14.6v.2c6 4.3 13.2 6.6 20.7 6.6z"/><path fill="#f9bc15" d="M40.2 41.9c-14.9.1-28.1 9.3-32.9 22.8-4.8 13.6 0 28.5 11.8 37.3l15.6-14.9c-8.6-3.7-10.6-14.5-4-20.8 6.6-6.4 17.8-4.4 21.7 3.8L68 55.2C61.4 46.9 51.1 42 40.2 42.1z"/></svg>
|
||||
|
After Width: | Height: | Size: 805 B |
@@ -23,6 +23,13 @@
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
.cloud-service-data-collected-table-heading-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
.cloud-service-data-collected-table-logs {
|
||||
@@ -32,3 +39,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cloud-service-data-collected-table-tooltip {
|
||||
max-width: 280px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -3,16 +3,19 @@ import {
|
||||
CloudintegrationtypesCollectedLogAttributeDTO,
|
||||
CloudintegrationtypesCollectedMetricDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { BarChart, ScrollText } from '@signozhq/icons';
|
||||
import { BarChart, Info, ScrollText } from '@signozhq/icons';
|
||||
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import './CloudServiceDataCollected.styles.scss';
|
||||
|
||||
function CloudServiceDataCollected({
|
||||
logsData,
|
||||
metricsData,
|
||||
metricsInfoTooltip,
|
||||
}: {
|
||||
logsData: CloudintegrationtypesCollectedLogAttributeDTO[] | null | undefined;
|
||||
metricsData: CloudintegrationtypesCollectedMetricDTO[] | null | undefined;
|
||||
metricsInfoTooltip?: string;
|
||||
}): JSX.Element {
|
||||
const logsColumns = [
|
||||
{
|
||||
@@ -84,6 +87,25 @@ function CloudServiceDataCollected({
|
||||
<div className="cloud-service-data-collected-table-heading">
|
||||
<BarChart size={14} />
|
||||
Metrics
|
||||
{metricsInfoTooltip && (
|
||||
<TooltipProvider>
|
||||
<TooltipSimple
|
||||
title={metricsInfoTooltip}
|
||||
side="top"
|
||||
tooltipContentProps={{
|
||||
className: 'cloud-service-data-collected-table-tooltip',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="cloud-service-data-collected-table-heading-info"
|
||||
aria-label="About the metrics listed below"
|
||||
data-testid="data-collected-metrics-info"
|
||||
>
|
||||
<Info size={12} />
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<Table
|
||||
columns={metricsColumns}
|
||||
@@ -97,4 +119,8 @@ function CloudServiceDataCollected({
|
||||
);
|
||||
}
|
||||
|
||||
CloudServiceDataCollected.defaultProps = {
|
||||
metricsInfoTooltip: undefined,
|
||||
};
|
||||
|
||||
export default CloudServiceDataCollected;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
SignalType,
|
||||
TelemetryFieldKey,
|
||||
} from 'types/api/v5/queryRange';
|
||||
@@ -54,7 +55,7 @@ function OtherFields({
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext as string),
|
||||
signal: attr.signal as SignalType,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType,
|
||||
fieldDataType: attr.fieldDataType as FieldDataType,
|
||||
}),
|
||||
);
|
||||
const addedIds = new Set(
|
||||
|
||||
@@ -6,7 +6,6 @@ import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { VIEWS } from './constants';
|
||||
import { MouseEventHandler } from 'react';
|
||||
|
||||
export type LogDetailProps = {
|
||||
log: ILog | null;
|
||||
@@ -17,8 +16,6 @@ export type LogDetailProps = {
|
||||
logs?: ILog[];
|
||||
onNavigateLog?: (log: ILog) => void;
|
||||
onScrollToLog?: (logId: string) => void;
|
||||
handleOpenInExplorer?: MouseEventHandler;
|
||||
getContainer?: DrawerProps['getContainer'];
|
||||
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
|
||||
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
|
||||
Pick<DrawerProps, 'onClose'>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { useSelector } from 'react-redux'; // old code, TODO: fix this correctly
|
||||
import { useCopyToClipboard, useLocation } from 'react-use';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Drawer, Tooltip } from 'antd';
|
||||
@@ -13,6 +14,9 @@ import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySear
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import Overview from 'container/LogDetailedView/Overview';
|
||||
@@ -27,6 +31,8 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -44,9 +50,12 @@ import {
|
||||
} from '@signozhq/icons';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { ILogBody } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
|
||||
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
|
||||
@@ -66,8 +75,6 @@ function LogDetailInner({
|
||||
logs,
|
||||
onNavigateLog,
|
||||
onScrollToLog,
|
||||
handleOpenInExplorer,
|
||||
getContainer,
|
||||
}: LogDetailInnerProps): JSX.Element {
|
||||
const initialContextQuery = useInitialQuery(log);
|
||||
const [contextQuery, setContextQuery] = useState<Query | undefined>(
|
||||
@@ -84,7 +91,7 @@ function LogDetailInner({
|
||||
|
||||
const [filters, setFilters] = useState<TagFilter | null>(null);
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const { stagedQuery, updateAllQueriesOperators } = useQueryBuilder();
|
||||
|
||||
// Handle clicks outside to close drawer, except on explicitly ignored regions
|
||||
useEffect(() => {
|
||||
@@ -179,6 +186,11 @@ function LogDetailInner({
|
||||
});
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const location = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
@@ -234,6 +246,25 @@ function LogDetailInner({
|
||||
});
|
||||
};
|
||||
|
||||
// Go to logs explorer page with the log data
|
||||
const handleOpenInExplorer = (e?: React.MouseEvent): void => {
|
||||
const queryParams = {
|
||||
[QueryParams.activeLogId]: `"${log?.id}"`,
|
||||
[QueryParams.startTime]: minTime?.toString() || '',
|
||||
[QueryParams.endTime]: maxTime?.toString() || '',
|
||||
[QueryParams.compositeQuery]: JSON.stringify(
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap[DataSource.LOGS],
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.LOGS,
|
||||
),
|
||||
),
|
||||
};
|
||||
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`, {
|
||||
newTab: !!e && isModifierKeyPressed(e),
|
||||
});
|
||||
};
|
||||
|
||||
const handleQueryExpressionChange = useCallback(
|
||||
(value: string, queryIndex: number) => {
|
||||
// update the query at the given index
|
||||
@@ -302,6 +333,13 @@ function LogDetailInner({
|
||||
}
|
||||
};
|
||||
|
||||
// Only show when opened from infra monitoring page
|
||||
const showOpenInExplorerBtn = useMemo(
|
||||
() => location.pathname?.includes('/infrastructure-monitoring'),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const logType = log?.attributes_string?.log_level || LogType.INFO;
|
||||
const currentLogIndex = logs ? logs.findIndex((l) => l.id === log.id) : -1;
|
||||
const isPrevDisabled =
|
||||
@@ -336,7 +374,6 @@ function LogDetailInner({
|
||||
width="60%"
|
||||
mask={false}
|
||||
maskClosable={false}
|
||||
getContainer={getContainer}
|
||||
title={
|
||||
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
|
||||
<div className="log-detail-drawer__title-left">
|
||||
@@ -374,7 +411,7 @@ function LogDetailInner({
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{handleOpenInExplorer && (
|
||||
{showOpenInExplorerBtn && (
|
||||
<div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -401,11 +438,7 @@ function LogDetailInner({
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||
>
|
||||
<div
|
||||
className="log-detail-drawer__content"
|
||||
data-log-detail-ignore="true"
|
||||
data-testid="log-detail-drawer"
|
||||
>
|
||||
<div className="log-detail-drawer__content" data-log-detail-ignore="true">
|
||||
<div className="log-detail-drawer__log">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
|
||||
<Tooltip
|
||||
|
||||
@@ -25,12 +25,12 @@ import CodeMirror, {
|
||||
} from '@uiw/react-codemirror';
|
||||
import { Button, Popover, Tooltip } from 'antd';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
|
||||
import { QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import { tracesAggregateOperatorOptions } from 'constants/queryBuilderOperators';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Info, TriangleAlert } from '@signozhq/icons';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { FieldDataType } from 'types/api/v5/queryRange';
|
||||
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
|
||||
|
||||
import { useQueryBuilderV2Context } from '../../QueryBuilderV2Context';
|
||||
@@ -323,16 +323,16 @@ function QueryAggregationSelect({
|
||||
TracesAggregatorOperator.RATE,
|
||||
];
|
||||
|
||||
const fieldDataType: FieldDataType | undefined =
|
||||
const fieldDataType =
|
||||
functionContextForFetch &&
|
||||
operatorsWithoutDataType.includes(functionContextForFetch)
|
||||
? undefined
|
||||
: 'number';
|
||||
: QUERY_BUILDER_KEY_TYPES.NUMBER;
|
||||
|
||||
return getKeySuggestions({
|
||||
signal: queryData.dataSource,
|
||||
searchText: '',
|
||||
fieldDataType,
|
||||
fieldDataType: fieldDataType as QUERY_BUILDER_KEY_TYPES,
|
||||
});
|
||||
},
|
||||
{
|
||||
|
||||
@@ -49,11 +49,7 @@ import { unquote } from 'utils/stringUtils';
|
||||
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
|
||||
import type { SignalType } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
queryExamples,
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import { queryExamples, SUGGESTIONS_SECTION } from './constants';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -102,15 +98,6 @@ interface QuerySearchProps {
|
||||
showFilterSuggestionsWithoutMetric?: boolean;
|
||||
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
|
||||
initialExpression?: string;
|
||||
/** When set, replaces the generic value-suggestion API with a custom fetcher. */
|
||||
valueSuggestionsOverride?: (
|
||||
key: string,
|
||||
searchText: string,
|
||||
) => Promise<{
|
||||
stringValues: string[];
|
||||
numberValues: number[];
|
||||
complete: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
function QuerySearch({
|
||||
@@ -124,7 +111,6 @@ function QuerySearch({
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
initialExpression,
|
||||
metricNamespace,
|
||||
valueSuggestionsOverride,
|
||||
}: QuerySearchProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
|
||||
@@ -367,7 +353,7 @@ function QuerySearch({
|
||||
);
|
||||
|
||||
const debouncedFetchKeySuggestions = useMemo(
|
||||
() => debounce(fetchKeySuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
|
||||
() => debounce(fetchKeySuggestions, 300),
|
||||
[fetchKeySuggestions],
|
||||
);
|
||||
|
||||
@@ -494,24 +480,13 @@ function QuerySearch({
|
||||
const sanitizedSearchText = searchText ? searchText?.trim() : '';
|
||||
|
||||
try {
|
||||
const values = valueSuggestionsOverride
|
||||
? await valueSuggestionsOverride(key, sanitizedSearchText)
|
||||
: await getValueSuggestions({
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
}).then((response) => {
|
||||
const responseData = response.data as any;
|
||||
const data = responseData.data || {};
|
||||
const values = data.values || {};
|
||||
return {
|
||||
stringValues: values.stringValues || [],
|
||||
numberValues: values.numberValues || [],
|
||||
complete: data.complete ?? false,
|
||||
};
|
||||
});
|
||||
const response = await getValueSuggestions({
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
});
|
||||
|
||||
// Skip updates if component unmounted or key changed
|
||||
if (
|
||||
@@ -523,6 +498,8 @@ function QuerySearch({
|
||||
}
|
||||
|
||||
// Process the response data
|
||||
const responseData = response.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
|
||||
@@ -603,12 +580,11 @@ function QuerySearch({
|
||||
debouncedMetricName,
|
||||
signalSource,
|
||||
toggleSuggestions,
|
||||
valueSuggestionsOverride,
|
||||
],
|
||||
);
|
||||
|
||||
const debouncedFetchValueSuggestions = useMemo(
|
||||
() => debounce(fetchValueSuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
|
||||
() => debounce(fetchValueSuggestions, 300),
|
||||
[fetchValueSuggestions],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export const RECENTS_SECTION = { name: 'Recent searches', rank: 1 } as const;
|
||||
export const SUGGESTIONS_SECTION = { name: 'Suggestions', rank: 2 } as const;
|
||||
|
||||
export const SUGGESTION_FETCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// TODO: move to using TelemetrytypesFieldContextDTO when we migrate getKeySuggestions API (Part of https://github.com/SigNoz/engineering-pod/issues/5289)
|
||||
export const FIELD_CONTEXTS = [
|
||||
'attribute',
|
||||
|
||||
@@ -23,13 +23,6 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Shrink the suggestion-fetch debounce (300ms in prod) so these integration
|
||||
// tests aren't paced by it; coalescing semantics stay intact.
|
||||
jest.mock('../QuerySearch/constants', () => ({
|
||||
...jest.requireActual('../QuerySearch/constants'),
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS: 30,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
|
||||
const handleRunQuery = jest.fn();
|
||||
return {
|
||||
|
||||
@@ -92,76 +92,52 @@
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.only-btn,
|
||||
.only-btn {
|
||||
cursor: not-allowed;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
cursor: not-allowed;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Stack "Only"/"All" and "Toggle" in a single cell so revealing
|
||||
// one swaps in place instead of shifting the row layout.
|
||||
.value-actions {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: end;
|
||||
|
||||
> * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.only-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.only-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-value-section:hover {
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
.only-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
// `display` is animated as a discrete property so the button
|
||||
// stays mounted through its fade-out on exit.
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hovering the label/value area reveals the "Only"/"All" action.
|
||||
.checkbox-value-section:hover .only-btn {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
// Entry animation start state (fires on the display switch).
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
// Hovering the checkbox reveals the "Toggle" action.
|
||||
.check-box:hover ~ .checkbox-value-section .toggle-btn {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.only-btn,
|
||||
.value:hover {
|
||||
.toggle-btn {
|
||||
transition: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Skeleton } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFilterChangeEventData,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
@@ -29,12 +28,11 @@ interface ICheckboxProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
const { source, filter, onFilterChange, onQuickFilterChange } = props;
|
||||
const { source, filter, onFilterChange } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
@@ -63,7 +61,6 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
onQuickFilterChange,
|
||||
});
|
||||
|
||||
const setSearchTextDebounced = useDebouncedFn((...args) => {
|
||||
|
||||
@@ -53,14 +53,12 @@ function CheckboxValueRow({
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
<div className="value-actions">
|
||||
<Button type="text" className="only-btn">
|
||||
{onlyButtonLabel}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="text" className="only-btn">
|
||||
{onlyButtonLabel}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFilterChangeEventData,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -20,7 +19,6 @@ interface UseCheckboxFilterActionsProps {
|
||||
attributeValues: string[];
|
||||
activeQueryIndex: number;
|
||||
onFilterChange?: ((query: Query) => void) | null;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
}
|
||||
|
||||
interface UseCheckboxFilterActionsReturn {
|
||||
@@ -44,7 +42,6 @@ function useCheckboxFilterActions({
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
onQuickFilterChange,
|
||||
}: UseCheckboxFilterActionsProps): UseCheckboxFilterActionsReturn {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
@@ -63,34 +60,20 @@ function useCheckboxFilterActions({
|
||||
previousState?: CheckedState,
|
||||
sectionType?: SectionType,
|
||||
): void => {
|
||||
const updatedQuery = applyCheckboxToggle({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
previousState,
|
||||
sectionType,
|
||||
});
|
||||
|
||||
dispatch(updatedQuery);
|
||||
|
||||
if (onQuickFilterChange) {
|
||||
const queryData = updatedQuery.builder.queryData[activeQueryIndex];
|
||||
const expression = queryData?.filter?.expression || '';
|
||||
const filterItemKeys = (queryData?.filters?.items || [])
|
||||
.map((item) => item.key?.key)
|
||||
.filter((key): key is string => !!key);
|
||||
|
||||
onQuickFilterChange({
|
||||
filterKey: filter.attributeKey.key,
|
||||
expression,
|
||||
filterItemKeys,
|
||||
});
|
||||
}
|
||||
dispatch(
|
||||
applyCheckboxToggle({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
previousState,
|
||||
sectionType,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onClear = (): void => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFilterChangeEventData,
|
||||
QuickFilterCheckboxUseFieldApis,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
@@ -34,15 +33,13 @@ interface CheckboxFilterV2Props {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
useFieldApis: QuickFilterCheckboxUseFieldApis;
|
||||
}
|
||||
|
||||
export default function CheckboxFilterV2(
|
||||
props: CheckboxFilterV2Props,
|
||||
): JSX.Element {
|
||||
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
|
||||
props;
|
||||
const { source, filter, onFilterChange, useFieldApis } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
|
||||
@@ -106,7 +103,6 @@ export default function CheckboxFilterV2(
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
onQuickFilterChange,
|
||||
});
|
||||
|
||||
const setSearchTextDebounced = useDebouncedFn((...args) => {
|
||||
|
||||
@@ -49,7 +49,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
handleFilterVisibilityChange,
|
||||
source,
|
||||
onFilterChange,
|
||||
onQuickFilterChange,
|
||||
signal,
|
||||
showFilterCollapse = true,
|
||||
showQueryName = true,
|
||||
@@ -306,7 +305,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
useFieldApis={useFieldApis}
|
||||
/>
|
||||
) : (
|
||||
@@ -315,7 +313,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
/>
|
||||
);
|
||||
case FiltersType.DURATION:
|
||||
@@ -336,7 +333,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
useFieldApis={useFieldApis}
|
||||
/>
|
||||
) : (
|
||||
@@ -345,7 +341,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,18 +42,11 @@ export interface IQuickFiltersConfig {
|
||||
defaultOpen: boolean;
|
||||
}
|
||||
|
||||
export interface QuickFilterChangeEventData {
|
||||
filterKey: string;
|
||||
expression: string;
|
||||
filterItemKeys: string[];
|
||||
}
|
||||
|
||||
export interface IQuickFiltersProps {
|
||||
config: IQuickFiltersConfig[];
|
||||
handleFilterVisibilityChange: () => void;
|
||||
source: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
signal?: SignalType;
|
||||
className?: string;
|
||||
showFilterCollapse?: boolean;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -88,7 +88,6 @@ function TanStackCustomTableRow<TData, TItemKey = string>({
|
||||
{...props}
|
||||
className={rowClassName}
|
||||
style={rowStyle}
|
||||
data-testid={context?.getRowTestId?.(rowData)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={clearHovered}
|
||||
>
|
||||
@@ -155,12 +154,6 @@ function areTableRowPropsEqual<TData, TItemKey = string>(
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevTestId = prev.context?.getRowTestId?.(prevData) ?? '';
|
||||
const nextTestId = next.context?.getRowTestId?.(nextData) ?? '';
|
||||
if (prevTestId !== nextTestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevStyle = prev.context?.getRowStyle?.(prevData);
|
||||
const nextStyle = next.context?.getRowStyle?.(nextData);
|
||||
if (prevStyle !== nextStyle) {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { type ReactNode, useLayoutEffect, useMemo } from 'react';
|
||||
import { useLayoutEffect, type ReactNode } from 'react';
|
||||
|
||||
import { chromePerformanceTanstackTableEndHover } from './perfDevtools';
|
||||
import { useIsRowHovered } from './TanStackTableStateContext';
|
||||
import {
|
||||
TooltipContentProps,
|
||||
TooltipSimple,
|
||||
TooltipSimpleProps,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
|
||||
|
||||
export type HoverTooltipProps = Omit<TooltipSimpleProps, 'open'> & {
|
||||
rowId: string;
|
||||
@@ -26,28 +22,9 @@ export function TanStackHoverTooltip({
|
||||
}
|
||||
}, [isHovered, rowId]);
|
||||
|
||||
const tooltipContentProps = useMemo(
|
||||
() =>
|
||||
({
|
||||
onPointerDownOutside: (e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
}) satisfies TooltipContentProps,
|
||||
[],
|
||||
);
|
||||
|
||||
if (!isHovered) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipSimple
|
||||
delayDuration={700}
|
||||
tooltipContentProps={tooltipContentProps}
|
||||
{...tooltipProps}
|
||||
>
|
||||
{children}
|
||||
</TooltipSimple>
|
||||
);
|
||||
return <TooltipSimple {...tooltipProps}>{children}</TooltipSimple>;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
|
||||
// Stable references via destructuring, keep them as is
|
||||
const onRowClick = context?.onRowClick;
|
||||
const onRowClickNewTab = context?.onRowClickNewTab;
|
||||
const onRowDeactivate = context?.onRowDeactivate;
|
||||
const isRowActive = context?.isRowActive;
|
||||
const getRowKeyData = context?.getRowKeyData;
|
||||
const rowIndex = row.index;
|
||||
@@ -51,9 +52,21 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
|
||||
}
|
||||
|
||||
const isActive = isRowActive?.(rowData) ?? false;
|
||||
onRowClick?.(rowData, itemKey, { isActive });
|
||||
if (isActive && onRowDeactivate) {
|
||||
onRowDeactivate();
|
||||
} else {
|
||||
onRowClick?.(rowData, itemKey);
|
||||
}
|
||||
},
|
||||
[isRowActive, onRowClick, onRowClickNewTab, rowData, getRowKeyData, rowIndex],
|
||||
[
|
||||
isRowActive,
|
||||
onRowDeactivate,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
rowData,
|
||||
getRowKeyData,
|
||||
rowIndex,
|
||||
],
|
||||
);
|
||||
|
||||
if (itemKind === 'expansion') {
|
||||
@@ -107,6 +120,7 @@ function areRowCellsPropsEqual<TData>(
|
||||
prev.columnVisibilityKey === next.columnVisibilityKey &&
|
||||
prev.context?.onRowClick === next.context?.onRowClick &&
|
||||
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
|
||||
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
|
||||
prev.context?.isRowActive === next.context?.isRowActive &&
|
||||
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
|
||||
prev.context?.renderRowActions === next.context?.renderRowActions &&
|
||||
|
||||
@@ -51,7 +51,6 @@ import { useColumnHandlers } from './useColumnHandlers';
|
||||
import { useColumnState } from './useColumnState';
|
||||
import { useEffectiveData } from './useEffectiveData';
|
||||
import { useFlatItems } from './useFlatItems';
|
||||
import { useResetScroll } from './useResetScroll';
|
||||
import { useRowKeyData } from './useRowKeyData';
|
||||
import { useTableParams } from './useTableParams';
|
||||
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
|
||||
@@ -93,11 +92,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
getGroupKey,
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
onSort,
|
||||
activeRowIndex,
|
||||
renderExpandedRow,
|
||||
@@ -111,7 +110,6 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
suffixPaginationContent,
|
||||
enableAlternatingRowColors,
|
||||
disableVirtualScroll,
|
||||
resetScrollKey,
|
||||
}: TanStackTableProps<TData, TItemKey>,
|
||||
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
|
||||
): JSX.Element {
|
||||
@@ -326,8 +324,6 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
});
|
||||
}, [flatIndexForActiveRow]);
|
||||
|
||||
useResetScroll(virtuosoRef, resetScrollKey);
|
||||
|
||||
const { sensors, columnIds, handleDragEnd } = useColumnDnd({
|
||||
columns: effectiveColumns,
|
||||
onColumnOrderChange: handleColumnOrderChange,
|
||||
@@ -382,11 +378,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
() => ({
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
colCount: visibleColumnsCount,
|
||||
@@ -400,11 +396,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
[
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
visibleColumnsCount,
|
||||
|
||||
@@ -85,9 +85,7 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: false,
|
||||
});
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
|
||||
});
|
||||
|
||||
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
|
||||
@@ -119,19 +117,17 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '', {
|
||||
isActive: false,
|
||||
});
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
});
|
||||
|
||||
it('calls onRowClick with isActive: true when the row is active', async () => {
|
||||
// The table no longer owns open/close — it reports the active state and the
|
||||
// consumer routes the click. An active row must still fire onRowClick.
|
||||
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: () => true,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
@@ -156,44 +152,8 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onRowClick with isActive: false when the row is not active', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
isRowActive: () => false,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: false,
|
||||
});
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render renderRowActions before hover', () => {
|
||||
|
||||
@@ -611,7 +611,6 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
'1',
|
||||
{ isActive: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -640,7 +639,6 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ isActive: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -661,15 +659,15 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(row).toHaveClass('tableRowActive');
|
||||
});
|
||||
|
||||
it('calls onRowClick with isActive: true when clicking the active row', async () => {
|
||||
// The consumer owns open/close routing — the table just reports the
|
||||
// active state via the click context.
|
||||
it('calls onRowDeactivate when clicking active row', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: (row) => row.id === '1',
|
||||
},
|
||||
});
|
||||
@@ -680,11 +678,8 @@ describe('TanStackTableView Integration', () => {
|
||||
|
||||
await user.click(screen.getByText('Item 1'));
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1' }),
|
||||
expect.anything(),
|
||||
{ isActive: true },
|
||||
);
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens in new tab on ctrl+click', async () => {
|
||||
|
||||
@@ -217,55 +217,6 @@ describe('useColumnState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('storageKey changes', () => {
|
||||
it('does not auto-show hidden columns when switching between different storageKeys', () => {
|
||||
// Regression test: when switching Pods→Nodes→Pods, hidden columns were
|
||||
// incorrectly shown because prevColumnIdsRef wasn't reset on storageKey change.
|
||||
// The autoShowNewlyAddedColumns effect would see pod columns as "new" (not in
|
||||
// node columns) and unhide them.
|
||||
const KEY_PODS = 'k8s-pods';
|
||||
const KEY_NODES = 'k8s-nodes';
|
||||
|
||||
const podColumns = [
|
||||
col('podName'),
|
||||
col('podStatus'),
|
||||
col('cpu_request', { defaultVisibility: false }),
|
||||
col('memory_request', { defaultVisibility: false }),
|
||||
];
|
||||
|
||||
const nodeColumns = [
|
||||
col('nodeName'),
|
||||
col('nodeStatus'),
|
||||
col('cpu'),
|
||||
col('memory'),
|
||||
];
|
||||
|
||||
// Initialize both tables
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(KEY_PODS, podColumns);
|
||||
useColumnStore.getState().initializeFromDefaults(KEY_NODES, nodeColumns);
|
||||
});
|
||||
|
||||
// Start with pods - cpu_request and memory_request should be hidden
|
||||
const { result, rerender } = renderHook(
|
||||
({ storageKey, columns }) => useColumnState({ storageKey, columns }),
|
||||
{ initialProps: { storageKey: KEY_PODS, columns: podColumns } },
|
||||
);
|
||||
|
||||
expect(result.current.hiddenColumnIds).toContain('cpu_request');
|
||||
expect(result.current.hiddenColumnIds).toContain('memory_request');
|
||||
|
||||
// Switch to nodes
|
||||
rerender({ storageKey: KEY_NODES, columns: nodeColumns });
|
||||
|
||||
// Switch back to pods - hidden columns should STILL be hidden
|
||||
rerender({ storageKey: KEY_PODS, columns: podColumns });
|
||||
|
||||
expect(result.current.hiddenColumnIds).toContain('cpu_request');
|
||||
expect(result.current.hiddenColumnIds).toContain('memory_request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
it('hideColumn hides a column', () => {
|
||||
const columns = [col('a'), col('b')];
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { RefObject } from 'react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { TableVirtuosoHandle } from 'react-virtuoso';
|
||||
|
||||
import { useResetScroll } from '../useResetScroll';
|
||||
|
||||
function createMockRef(scrollTo: jest.Mock): RefObject<TableVirtuosoHandle> {
|
||||
return {
|
||||
current: {
|
||||
scrollToIndex: jest.fn(),
|
||||
scrollIntoView: jest.fn(),
|
||||
scrollTo,
|
||||
scrollBy: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('useResetScroll', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not call scrollTo on initial mount', () => {
|
||||
const scrollTo = jest.fn();
|
||||
const ref = createMockRef(scrollTo);
|
||||
|
||||
renderHook(() => useResetScroll(ref, 'initial-key'));
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls scrollTo when key changes', () => {
|
||||
const scrollTo = jest.fn();
|
||||
const ref = createMockRef(scrollTo);
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ resetKey }) => useResetScroll(ref, resetKey),
|
||||
{ initialProps: { resetKey: 'key-1' } },
|
||||
);
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ resetKey: 'key-2' });
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ left: 0 });
|
||||
});
|
||||
|
||||
it('calls scrollTo once per key change', () => {
|
||||
const scrollTo = jest.fn();
|
||||
const ref = createMockRef(scrollTo);
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ resetKey }) => useResetScroll(ref, resetKey),
|
||||
{ initialProps: { resetKey: 'key-1' } },
|
||||
);
|
||||
|
||||
rerender({ resetKey: 'key-2' });
|
||||
rerender({ resetKey: 'key-3' });
|
||||
rerender({ resetKey: 'key-4' });
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does not call scrollTo when key unchanged', () => {
|
||||
const scrollTo = jest.fn();
|
||||
const ref = createMockRef(scrollTo);
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ resetKey }) => useResetScroll(ref, resetKey),
|
||||
{ initialProps: { resetKey: 'same-key' } },
|
||||
);
|
||||
|
||||
rerender({ resetKey: 'same-key' });
|
||||
rerender({ resetKey: 'same-key' });
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles null ref.current gracefully', () => {
|
||||
const ref: RefObject<TableVirtuosoHandle | null> = { current: null };
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ resetKey }) => useResetScroll(ref, resetKey),
|
||||
{ initialProps: { resetKey: 'key-1' } },
|
||||
);
|
||||
|
||||
expect(() => rerender({ resetKey: 'key-2' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('calls scrollTo when changing from undefined to defined', () => {
|
||||
const scrollTo = jest.fn();
|
||||
const ref = createMockRef(scrollTo);
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ resetKey }) => useResetScroll(ref, resetKey),
|
||||
{ initialProps: { resetKey: undefined as string | undefined } },
|
||||
);
|
||||
|
||||
rerender({ resetKey: 'new-key' });
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ left: 0 });
|
||||
});
|
||||
});
|
||||
@@ -116,11 +116,9 @@ export * from './useTableParams';
|
||||
* getItemKey={(row) => row.id}
|
||||
* isRowActive={(row) => row.id === selectedId}
|
||||
* activeRowIndex={selectedIndex}
|
||||
* // The table reports the click + the row's active state; the consumer owns open/close.
|
||||
* onRowClick={(row, itemKey, { isActive }) =>
|
||||
* setSelectedId(isActive ? undefined : itemKey)
|
||||
* }
|
||||
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
|
||||
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
|
||||
* onRowDeactivate={() => setSelectedId(undefined)}
|
||||
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
|
||||
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
|
||||
* renderRowActions={(row) => <Button size="small">Open</Button>}
|
||||
@@ -215,16 +213,6 @@ export * from './useTableParams';
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Reset scroll on context change — use `resetScrollKey` to scroll back to start
|
||||
* when the data context changes (e.g., switching between categories or tabs).
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* resetScrollKey={selectedCategory}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example useTableParams — manages pagination state with URL sync and persistence
|
||||
*
|
||||
* The `useTableParams` hook handles page, limit, orderBy, and expanded state. It can sync
|
||||
|
||||
@@ -81,19 +81,15 @@ export type FlatItem<TData> =
|
||||
| { kind: 'row'; row: TanStackRowType<TData> }
|
||||
| { kind: 'expansion'; row: TanStackRowType<TData> };
|
||||
|
||||
export type RowClickContext = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type TableRowContext<TData, TItemKey = string> = {
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
getRowTestId?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
rowKey: string,
|
||||
@@ -182,12 +178,12 @@ export type TanStackTableProps<TData, TItemKey = string> = {
|
||||
getGroupKey?: (row: TData) => Record<string, string>;
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
getRowTestId?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
activeRowIndex?: number;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
@@ -213,8 +209,6 @@ export type TanStackTableProps<TData, TItemKey = string> = {
|
||||
enableAlternatingRowColors?: boolean;
|
||||
/** Disable virtual scrolling and render all rows at once. Cannot be used with onEndReached. */
|
||||
disableVirtualScroll?: boolean;
|
||||
/** When this value changes, the table's scroll resets to the start. Useful for resetting scroll when the data context changes (e.g., switching categories). */
|
||||
resetScrollKey?: string;
|
||||
};
|
||||
|
||||
export type TanStackTableHandle = TableVirtuosoHandle & {
|
||||
|
||||
@@ -67,13 +67,6 @@ export function useColumnState<TData>({
|
||||
|
||||
const columnSizing = useStoreSizing(storageKey ?? '');
|
||||
const prevColumnIdsRef = useRef<Set<string> | null>(null);
|
||||
const prevStorageKeyRef = useRef<string | undefined>(storageKey);
|
||||
|
||||
// Reset prevColumnIdsRef when storageKey changes to avoid cross-table interference
|
||||
if (prevStorageKeyRef.current !== storageKey) {
|
||||
prevColumnIdsRef.current = null;
|
||||
prevStorageKeyRef.current = storageKey;
|
||||
}
|
||||
|
||||
useEffect(
|
||||
function autoShowNewlyAddedColumns(): void {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { RefObject, useEffect, useRef } from 'react';
|
||||
import type { TableVirtuosoHandle } from 'react-virtuoso';
|
||||
|
||||
export function useResetScroll(
|
||||
scrollRef: RefObject<TableVirtuosoHandle | null>,
|
||||
resetKey: string | undefined,
|
||||
): void {
|
||||
const prevKeyRef = useRef(resetKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevKeyRef.current !== resetKey) {
|
||||
prevKeyRef.current = resetKey;
|
||||
scrollRef.current?.scrollTo({
|
||||
left: 0,
|
||||
});
|
||||
}
|
||||
}, [resetKey, scrollRef]);
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
.warning-popover-overlay {
|
||||
--antd-arrow-background-color: var(--l2-background);
|
||||
}
|
||||
|
||||
.warning-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--l2-background);
|
||||
|
||||
// === SECTION: Summary (Top)
|
||||
&__summary-section {
|
||||
@@ -15,25 +10,16 @@
|
||||
|
||||
&__summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&__summary-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
height: var(--spacing-12);
|
||||
}
|
||||
|
||||
&__summary-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ReactNode, useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Popover, PopoverProps } from 'antd';
|
||||
import ErrorIcon from 'assets/Error';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { BookOpenText, ChevronsDown, TriangleAlert } from '@signozhq/icons';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
@@ -31,18 +30,16 @@ export function WarningContent({ warning }: WarningContentProps): JSX.Element {
|
||||
{/* Summary Header */}
|
||||
<section className="warning-content__summary-section">
|
||||
<header className="warning-content__summary">
|
||||
{(warningCode || warningMessage) && (
|
||||
<div className="warning-content__summary-left">
|
||||
<div className="warning-content__icon-wrapper">
|
||||
<ErrorIcon />
|
||||
</div>
|
||||
|
||||
<div className="warning-content__summary-text">
|
||||
<h2 className="warning-content__warning-code">{warningCode}</h2>
|
||||
<p className="warning-content__warning-message">{warningMessage}</p>
|
||||
</div>
|
||||
<div className="warning-content__summary-left">
|
||||
<div className="warning-content__icon-wrapper">
|
||||
<ErrorIcon />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="warning-content__summary-text">
|
||||
<h2 className="warning-content__warning-code">{warningCode}</h2>
|
||||
<p className="warning-content__warning-message">{warningMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{warningUrl && (
|
||||
<div className="warning-content__summary-right">
|
||||
@@ -157,10 +154,6 @@ function WarningPopover({
|
||||
overlayInnerStyle={{ padding: 0 }}
|
||||
autoAdjustOverflow
|
||||
{...popoverProps}
|
||||
overlayClassName={cx(
|
||||
'warning-popover-overlay',
|
||||
popoverProps.overlayClassName,
|
||||
)}
|
||||
>
|
||||
{children || (
|
||||
<TriangleAlert
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { getNavigationReferrer } from 'lib/navigation';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { isCustomTimeRange } from 'store/globalTime';
|
||||
|
||||
export enum Events {
|
||||
UPDATE_GRAPH_VISIBILITY_STATE = 'UPDATE_GRAPH_VISIBILITY_STATE',
|
||||
UPDATE_GRAPH_MANAGER_TABLE = 'UPDATE_GRAPH_MANAGER_TABLE',
|
||||
@@ -45,155 +39,3 @@ export enum InfraMonitoringEvents {
|
||||
StatefulSet = 'statefulSet',
|
||||
Volumes = 'volumes',
|
||||
}
|
||||
|
||||
export function logInfraFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
source: 'quick_filter' | 'search' | 'host_status_toggle',
|
||||
expression: string,
|
||||
extraKeys?: string[],
|
||||
): void {
|
||||
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
|
||||
(pair) => pair.key,
|
||||
);
|
||||
|
||||
if (extraKeys) {
|
||||
extraKeys.forEach((key) => expressionKeys.push(key));
|
||||
}
|
||||
|
||||
if (expressionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent('infra_filter_customized', {
|
||||
entity_type: entityType,
|
||||
source,
|
||||
expression_keys: [...new Set(expressionKeys)],
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraMonitoringListViewedEvent(
|
||||
entity: InfraMonitoringEntity,
|
||||
): void {
|
||||
const referrer = getNavigationReferrer();
|
||||
|
||||
void logEvent('infra_list_viewed', {
|
||||
entity,
|
||||
referrer,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraTimeRangeCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
rangeLabel: string,
|
||||
): void {
|
||||
void logEvent('infra_time_range_customized', {
|
||||
entity_type: entityType,
|
||||
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraColumnCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
columnsList: string[],
|
||||
fontSize: string,
|
||||
maxLinesPerRow: number,
|
||||
source: 'list' | 'expanded',
|
||||
): void {
|
||||
void logEvent('infra_column_customized', {
|
||||
entity_type: entityType,
|
||||
columns_list: columnsList,
|
||||
font_size: fontSize,
|
||||
max_lines_per_row: maxLinesPerRow,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraColumnSortedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
columnKey: string,
|
||||
direction: 'asc' | 'desc',
|
||||
source: 'list' | 'expanded',
|
||||
): void {
|
||||
void logEvent('infra_column_sorted', {
|
||||
entity_type: entityType,
|
||||
column_key: columnKey,
|
||||
direction,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerTimeRangeCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
rangeLabel: string,
|
||||
): void {
|
||||
void logEvent('infra_drawer_time_range_customized', {
|
||||
entity_type: entityType,
|
||||
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
|
||||
expression: string,
|
||||
filterSource: 'search' | 'logs',
|
||||
): void {
|
||||
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
|
||||
(pair) => pair.key,
|
||||
);
|
||||
|
||||
if (expressionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent('infra_drawer_filter_customized', {
|
||||
entity_type: entityType,
|
||||
tab,
|
||||
expression_keys: [...new Set(expressionKeys)],
|
||||
filter_source: filterSource,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraGroupByCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
groupByKeysList: string[],
|
||||
): void {
|
||||
void logEvent('infra_group_by_customized', {
|
||||
entity_type: entityType,
|
||||
group_by_keys_list: groupByKeysList,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerTabViewedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
tab: string,
|
||||
isDefaultTab: boolean,
|
||||
): void {
|
||||
void logEvent('infra_drawer_tab_viewed', {
|
||||
entity_type: entityType,
|
||||
tab,
|
||||
is_default_tab: isDefaultTab,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraExplorerNavigatedEvent(params: {
|
||||
entityType: InfraMonitoringEntity;
|
||||
destination:
|
||||
| 'metrics_explorer'
|
||||
| 'logs_explorer'
|
||||
| 'traces_explorer'
|
||||
| 'k8s_list';
|
||||
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
|
||||
tab: string;
|
||||
sourceKey: string | null;
|
||||
drawerDurationMsAtNavigation: number | null;
|
||||
}): void {
|
||||
void logEvent('infra_explorer_navigated', {
|
||||
entity_type: params.entityType,
|
||||
destination: params.destination,
|
||||
source: params.source,
|
||||
tab: params.tab,
|
||||
source_key: params.sourceKey,
|
||||
drawer_duration_ms_at_navigation: params.drawerDurationMsAtNavigation,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,5 +45,4 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
|
||||
}
|
||||
|
||||
@@ -3,7 +3,3 @@ export const DASHBOARD_CACHE_TIME = 30_000;
|
||||
export const DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED = 0;
|
||||
|
||||
export const FIELD_API_CACHE_TIME = 60_000;
|
||||
|
||||
// intentionally lower since I only want to prevent refresh in background
|
||||
// after click in the row
|
||||
export const INFRA_MONITORING_DETAILS_CACHE_TIME = 1_000;
|
||||
|
||||
@@ -20,6 +20,10 @@ export const REACT_QUERY_KEY = {
|
||||
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
|
||||
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',
|
||||
ALERT_RULE_DETAILS: 'ALERT_RULE_DETAILS',
|
||||
ALERT_RULE_STATS: 'ALERT_RULE_STATS',
|
||||
ALERT_RULE_TOP_CONTRIBUTORS: 'ALERT_RULE_TOP_CONTRIBUTORS',
|
||||
ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE',
|
||||
ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH',
|
||||
GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS',
|
||||
TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE',
|
||||
GET_ALL_ALERTS: 'GET_ALL_ALERTS',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { StatsTimeSeriesItem } from 'types/api/alerts/def';
|
||||
|
||||
import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard';
|
||||
import StatsCard from '../StatsCard/StatsCard';
|
||||
@@ -26,59 +25,24 @@ type StatsCardsRendererProps = {
|
||||
};
|
||||
|
||||
// TODO(shaheer): render the DataStateRenderer inside the TotalTriggeredCard/AverageResolutionCard, it should display the title
|
||||
type AdaptedStatsData = {
|
||||
totalCurrentTriggers: number;
|
||||
totalPastTriggers: number;
|
||||
currentAvgResolutionTime: string;
|
||||
pastAvgResolutionTime: string;
|
||||
currentTriggersSeries: StatsTimeSeriesItem[];
|
||||
currentAvgResolutionTimeSeries: StatsTimeSeriesItem[];
|
||||
};
|
||||
|
||||
function StatsCardsRenderer({
|
||||
setTotalCurrentTriggers,
|
||||
}: StatsCardsRendererProps): JSX.Element {
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsStats();
|
||||
|
||||
const adaptedData = useMemo((): AdaptedStatsData | null => {
|
||||
if (!data?.data) {
|
||||
return null;
|
||||
}
|
||||
const statsData = data.data;
|
||||
|
||||
const adaptTimeSeries = (
|
||||
series: typeof statsData.currentTriggersSeries,
|
||||
): StatsTimeSeriesItem[] =>
|
||||
series?.values?.map((item) => ({
|
||||
timestamp: item.timestamp ?? 0,
|
||||
value: String(item.value ?? 0),
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
totalCurrentTriggers: statsData.totalCurrentTriggers,
|
||||
totalPastTriggers: statsData.totalPastTriggers,
|
||||
currentAvgResolutionTime: String(statsData.currentAvgResolutionTime),
|
||||
pastAvgResolutionTime: String(statsData.pastAvgResolutionTime),
|
||||
currentTriggersSeries: adaptTimeSeries(statsData.currentTriggersSeries),
|
||||
currentAvgResolutionTimeSeries: adaptTimeSeries(
|
||||
statsData.currentAvgResolutionTimeSeries,
|
||||
),
|
||||
};
|
||||
}, [data?.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (adaptedData?.totalCurrentTriggers !== undefined) {
|
||||
setTotalCurrentTriggers(adaptedData.totalCurrentTriggers);
|
||||
if (data?.payload?.data?.totalCurrentTriggers !== undefined) {
|
||||
setTotalCurrentTriggers(data.payload.data.totalCurrentTriggers);
|
||||
}
|
||||
}, [adaptedData, setTotalCurrentTriggers]);
|
||||
}, [data, setTotalCurrentTriggers]);
|
||||
|
||||
return (
|
||||
<DataStateRenderer
|
||||
isLoading={isLoading}
|
||||
isRefetching={isRefetching}
|
||||
isError={isError || !isValidRuleId || !ruleId}
|
||||
data={adaptedData}
|
||||
data={data?.payload?.data || null}
|
||||
>
|
||||
{(data): JSX.Element => {
|
||||
const {
|
||||
@@ -96,7 +60,7 @@ function StatsCardsRenderer({
|
||||
<TotalTriggeredCard
|
||||
totalCurrentTriggers={totalCurrentTriggers}
|
||||
totalPastTriggers={totalPastTriggers}
|
||||
timeSeries={currentTriggersSeries}
|
||||
timeSeries={currentTriggersSeries?.values}
|
||||
/>
|
||||
) : (
|
||||
<StatsCard
|
||||
@@ -113,7 +77,7 @@ function StatsCardsRenderer({
|
||||
<AverageResolutionCard
|
||||
currentAvgResolutionTime={currentAvgResolutionTime}
|
||||
pastAvgResolutionTime={pastAvgResolutionTime}
|
||||
timeSeries={currentAvgResolutionTimeSeries}
|
||||
timeSeries={currentAvgResolutionTimeSeries?.values}
|
||||
/>
|
||||
) : (
|
||||
<StatsCard
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetAlertRuleDetailsTopContributors } from 'pages/AlertDetails/hooks';
|
||||
import { labelsArrayToObject } from 'container/AlertHistory/utils/labelAdapters';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
|
||||
import { AlertRuleStats } from 'types/api/alerts/def';
|
||||
|
||||
import TopContributorsCard from '../TopContributorsCard/TopContributorsCard';
|
||||
|
||||
@@ -15,27 +13,15 @@ function TopContributorsRenderer({
|
||||
}: TopContributorsRendererProps): JSX.Element {
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTopContributors();
|
||||
const response = data?.payload?.data;
|
||||
|
||||
// TODO(shaheer): render the DataStateRenderer inside the TopContributorsCard, it should display the title and view all
|
||||
const adaptedData = useMemo((): AlertRuleTopContributors[] | null => {
|
||||
if (!data?.data) {
|
||||
return null;
|
||||
}
|
||||
return data.data.map((contributor) => ({
|
||||
fingerprint: contributor.fingerprint,
|
||||
count: contributor.count,
|
||||
labels: labelsArrayToObject(contributor.labels),
|
||||
relatedLogsLink: contributor.relatedLogsLink ?? '',
|
||||
relatedTracesLink: contributor.relatedTracesLink ?? '',
|
||||
}));
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<DataStateRenderer
|
||||
isLoading={isLoading}
|
||||
isRefetching={isRefetching}
|
||||
isError={isError || !isValidRuleId || !ruleId}
|
||||
data={adaptedData}
|
||||
data={response || null}
|
||||
>
|
||||
{(topContributorsData): JSX.Element => (
|
||||
<TopContributorsCard
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const ALERT_STATUS: Record<RuletypesAlertStateDTO, number> & {
|
||||
[key: string]: number;
|
||||
} = {
|
||||
[RuletypesAlertStateDTO.firing]: 0,
|
||||
[RuletypesAlertStateDTO.inactive]: 1,
|
||||
export const ALERT_STATUS: { [key: string]: number } = {
|
||||
firing: 0,
|
||||
inactive: 1,
|
||||
normal: 1,
|
||||
[RuletypesAlertStateDTO.pending]: 2,
|
||||
[RuletypesAlertStateDTO.recovering]: 2,
|
||||
'no-data': 3,
|
||||
[RuletypesAlertStateDTO.nodata]: 3,
|
||||
[RuletypesAlertStateDTO.disabled]: 4,
|
||||
muted: 5,
|
||||
'no-data': 2,
|
||||
disabled: 3,
|
||||
muted: 4,
|
||||
};
|
||||
|
||||
export const STATE_VS_COLOR: {
|
||||
@@ -22,10 +16,9 @@ export const STATE_VS_COLOR: {
|
||||
{
|
||||
0: { stroke: Color.BG_CHERRY_500, fill: Color.BG_CHERRY_500 },
|
||||
1: { stroke: Color.BG_FOREST_500, fill: Color.BG_FOREST_500 },
|
||||
2: { stroke: Color.BG_AMBER_500, fill: Color.BG_AMBER_500 },
|
||||
3: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
|
||||
4: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
|
||||
5: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
|
||||
2: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
|
||||
3: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
|
||||
4: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
|
||||
|
||||
import Graph from '../Graph/Graph';
|
||||
|
||||
@@ -20,16 +18,26 @@ function GraphWrapper({
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTimelineGraphData();
|
||||
|
||||
const adaptedData = useMemo((): AlertRuleTimelineGraphResponse[] | null => {
|
||||
if (!data?.data) {
|
||||
return null;
|
||||
}
|
||||
return data.data.map((item) => ({
|
||||
start: item.start,
|
||||
end: item.end,
|
||||
state: item.state as AlertRuleTimelineGraphResponse['state'],
|
||||
}));
|
||||
}, [data?.data]);
|
||||
// TODO(shaheer): uncomment when the API is ready for
|
||||
// const { startTime } = useAlertHistoryQueryParams();
|
||||
|
||||
// const [isVerticalGraph, setIsVerticalGraph] = useState(false);
|
||||
|
||||
// useEffect(() => {
|
||||
// const checkVerticalGraph = (): void => {
|
||||
// if (startTime) {
|
||||
// const startTimeDate = dayjs(Number(startTime));
|
||||
// const twentyFourHoursAgo = dayjs().subtract(
|
||||
// HORIZONTAL_GRAPH_HOURS_THRESHOLD,
|
||||
// DAYJS_MANIPULATE_TYPES.HOUR,
|
||||
// );
|
||||
|
||||
// setIsVerticalGraph(startTimeDate.isBefore(twentyFourHoursAgo));
|
||||
// }
|
||||
// };
|
||||
|
||||
// checkVerticalGraph();
|
||||
// }, [startTime]);
|
||||
|
||||
return (
|
||||
<div className="timeline-graph">
|
||||
@@ -41,7 +49,7 @@ function GraphWrapper({
|
||||
isLoading={isLoading}
|
||||
isError={isError || !isValidRuleId || !ruleId}
|
||||
isRefetching={isRefetching}
|
||||
data={adaptedData}
|
||||
data={data?.payload?.data || null}
|
||||
>
|
||||
{(data): JSX.Element => <Graph type="horizontal" data={data} />}
|
||||
</DataStateRenderer>
|
||||
|
||||
@@ -1,35 +1,11 @@
|
||||
.timeline-table {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
min-height: 600px;
|
||||
|
||||
&__filter {
|
||||
padding: 12px 16px;
|
||||
background: var(--l1-background);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&__filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__filter-search {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__filter--loading,
|
||||
&__filter--loading-skeleton {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
background: var(--l1-background);
|
||||
&-placeholder {
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
&-cell {
|
||||
padding: 12px 16px !important;
|
||||
vertical-align: baseline;
|
||||
@@ -47,9 +23,6 @@
|
||||
&-tbody > tr > td {
|
||||
border: none;
|
||||
}
|
||||
&-footer {
|
||||
background-color: var(--l1-background);
|
||||
}
|
||||
}
|
||||
|
||||
.label-filter {
|
||||
@@ -113,38 +86,4 @@
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
display: flex;
|
||||
align-self: flex-start;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
background: var(--l1-background);
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +1,52 @@
|
||||
import { HTMLAttributes, useCallback, useMemo } from 'react';
|
||||
import { Button, Skeleton, Table } from 'antd';
|
||||
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
|
||||
import { HTMLAttributes, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Table } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import {
|
||||
QuerySearchV2Provider,
|
||||
useExpression,
|
||||
useInputExpression,
|
||||
useQuerySearchOnChange,
|
||||
useQuerySearchOnRun,
|
||||
} from 'components/QueryBuilderV2';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { initialFilters } from 'constants/queryBuilder';
|
||||
import {
|
||||
useGetAlertRuleDetailsTimelineTable,
|
||||
useTimelineTable,
|
||||
} from 'pages/AlertDetails/hooks';
|
||||
import { labelsArrayToObject } from 'container/AlertHistory/utils/labelAdapters';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useAlertHistoryFilterSuggestions } from './useAlertHistoryFilterSuggestions';
|
||||
import { timelineTableColumns } from './useTimelineTable';
|
||||
|
||||
import './Table.styles.scss';
|
||||
|
||||
export const ALERT_HISTORY_EXPRESSION_KEY = 'alertHistoryExpression';
|
||||
function TimelineTable(): JSX.Element {
|
||||
const [filters, setFilters] = useState<TagFilter>(initialFilters);
|
||||
|
||||
function TimelineTableContent(): JSX.Element {
|
||||
const expression = useExpression();
|
||||
const inputExpression = useInputExpression();
|
||||
const querySearchOnChange = useQuerySearchOnChange();
|
||||
const querySearchOnRun = useQuerySearchOnRun();
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
isRefetching,
|
||||
isError,
|
||||
data,
|
||||
error,
|
||||
ruleId,
|
||||
refetch,
|
||||
cancel,
|
||||
} = useGetAlertRuleDetailsTimelineTable({ filterExpression: expression });
|
||||
|
||||
const apiError = useMemo(() => convertToApiError(error), [error]);
|
||||
|
||||
const { hardcodedAttributeKeys, valueSuggestionsOverride, isLoadingKeys } =
|
||||
useAlertHistoryFilterSuggestions(ruleId ?? null);
|
||||
|
||||
const { timelineData, totalItems, nextCursor } = useMemo(() => {
|
||||
const response = data?.data;
|
||||
const items: AlertRuleTimelineTableResponse[] | undefined =
|
||||
response?.items?.map((item) => {
|
||||
return {
|
||||
ruleID: item.ruleId,
|
||||
ruleName: item.ruleName,
|
||||
overallState: item.overallState as string,
|
||||
overallStateChanged: item.overallStateChanged,
|
||||
state: item.state as string,
|
||||
stateChanged: item.stateChanged,
|
||||
unixMilli: item.unixMilli,
|
||||
fingerprint: item.fingerprint,
|
||||
value: item.value,
|
||||
labels: labelsArrayToObject(item.labels),
|
||||
relatedLogsLink: item.relatedLogsLink,
|
||||
relatedTracesLink: item.relatedTracesLink,
|
||||
};
|
||||
});
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTimelineTable({ filters });
|
||||
|
||||
const { timelineData, totalItems, labels } = useMemo(() => {
|
||||
const response = data?.payload?.data;
|
||||
return {
|
||||
timelineData: items,
|
||||
totalItems: response?.total ?? 0,
|
||||
nextCursor: response?.nextCursor,
|
||||
timelineData: response?.items,
|
||||
totalItems: response?.total,
|
||||
labels: response?.labels,
|
||||
};
|
||||
}, [data?.data]);
|
||||
}, [data?.payload?.data]);
|
||||
|
||||
const {
|
||||
paginationConfig,
|
||||
onChangeHandler,
|
||||
handleNextPage,
|
||||
handlePrevPage,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
} = useTimelineTable({
|
||||
const { paginationConfig, onChangeHandler } = useTimelineTable({
|
||||
totalItems: totalItems ?? 0,
|
||||
nextCursor,
|
||||
});
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const handleRunQuery = useCallback(
|
||||
(updatedExpression?: string): void => {
|
||||
const nextExpression = updatedExpression ?? inputExpression;
|
||||
querySearchOnRun(nextExpression);
|
||||
|
||||
if (nextExpression === expression) {
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
[querySearchOnRun, refetch, inputExpression, expression],
|
||||
);
|
||||
|
||||
const queryData = useMemo(
|
||||
() => ({
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.LOGS,
|
||||
filter: { expression },
|
||||
expression,
|
||||
}),
|
||||
[expression],
|
||||
);
|
||||
if (isError || !isValidRuleId || !ruleId) {
|
||||
return <div>{t('something_went_wrong')}</div>;
|
||||
}
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTimelineTableResponse,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
void logEvent('Alert history: Timeline table row: Clicked', {
|
||||
logEvent('Alert history: Timeline table row: Clicked', {
|
||||
ruleId: record.ruleID,
|
||||
labels: record.labels,
|
||||
});
|
||||
@@ -129,106 +55,23 @@ function TimelineTableContent(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="timeline-table">
|
||||
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
|
||||
{!isLoadingKeys && hardcodedAttributeKeys ? (
|
||||
<div className="timeline-table__filter">
|
||||
<div className="timeline-table__filter-row">
|
||||
<div className="timeline-table__filter-search">
|
||||
<QuerySearch
|
||||
onChange={querySearchOnChange}
|
||||
queryData={queryData}
|
||||
dataSource={DataSource.LOGS}
|
||||
onRun={handleRunQuery}
|
||||
hardcodedAttributeKeys={hardcodedAttributeKeys}
|
||||
valueSuggestionsOverride={valueSuggestionsOverride}
|
||||
/>
|
||||
</div>
|
||||
<RunQueryBtn
|
||||
isLoadingQueries={isLoading || isRefetching}
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
handleCancelQuery={cancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="timeline-table__filter timeline-table__filter--loading">
|
||||
<Skeleton.Input
|
||||
className="timeline-table__filter--loading-skeleton"
|
||||
active
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Table
|
||||
rowKey={(row): string => `${row.fingerprint}-${row.value}-${row.unixMilli}`}
|
||||
columns={timelineTableColumns({
|
||||
filters,
|
||||
labels: labels ?? {},
|
||||
setFilters,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
})}
|
||||
onRow={handleRowClick}
|
||||
dataSource={timelineData}
|
||||
pagination={false}
|
||||
pagination={paginationConfig}
|
||||
size="middle"
|
||||
onChange={onChangeHandler}
|
||||
loading={isLoading || isRefetching}
|
||||
locale={{
|
||||
emptyText:
|
||||
isError && apiError ? (
|
||||
<div className="timeline-table__error">
|
||||
<ErrorContent error={apiError} />
|
||||
</div>
|
||||
) : undefined,
|
||||
}}
|
||||
footer={(): JSX.Element => (
|
||||
<div className="timeline-table__pagination">
|
||||
<div className="timeline-table__pagination-info">
|
||||
{paginationConfig.showTotal?.(totalItems, [
|
||||
totalItems === 0
|
||||
? 0
|
||||
: ((paginationConfig.current ?? 1) - 1) *
|
||||
(paginationConfig.pageSize ?? 10) +
|
||||
1,
|
||||
Math.min(
|
||||
(paginationConfig.current ?? 1) * (paginationConfig.pageSize ?? 10),
|
||||
totalItems,
|
||||
),
|
||||
])}
|
||||
</div>
|
||||
<div className="pagination-controls">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
disabled={!hasPrevPage}
|
||||
onClick={handlePrevPage}
|
||||
data-testid="timeline-prev-page"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
disabled={!hasNextPage}
|
||||
onClick={handleNextPage}
|
||||
data-testid="timeline-next-page"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineTable(): JSX.Element {
|
||||
return (
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={ALERT_HISTORY_EXPRESSION_KEY}
|
||||
initialExpression=""
|
||||
persistOnUnmount
|
||||
>
|
||||
<TimelineTableContent />
|
||||
</QuerySearchV2Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineTable;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
getRuleHistoryFilterValues,
|
||||
useGetRuleHistoryFilterKeys,
|
||||
} from 'api/generated/services/rules';
|
||||
import { useAlertHistoryQueryParams } from 'pages/AlertDetails/hooks';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { fieldContextToSuggestionContext } from 'container/AlertHistory/Timeline/Table/utils';
|
||||
|
||||
export interface AlertHistoryFilterSuggestions {
|
||||
hardcodedAttributeKeys: QueryKeyDataSuggestionsProps[];
|
||||
valueSuggestionsOverride: (
|
||||
key: string,
|
||||
searchText: string,
|
||||
) => Promise<{
|
||||
stringValues: string[];
|
||||
numberValues: number[];
|
||||
complete: boolean;
|
||||
}>;
|
||||
isLoadingKeys: boolean;
|
||||
}
|
||||
|
||||
export function useAlertHistoryFilterSuggestions(
|
||||
ruleId: string | null,
|
||||
): AlertHistoryFilterSuggestions {
|
||||
const { startTime, endTime } = useAlertHistoryQueryParams();
|
||||
|
||||
const { data: filterKeysData, isLoading: isLoadingKeys } =
|
||||
useGetRuleHistoryFilterKeys(
|
||||
{ id: ruleId ?? '' },
|
||||
{ startUnixMilli: startTime, endUnixMilli: endTime },
|
||||
{
|
||||
query: {
|
||||
enabled: !!ruleId,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const hardcodedAttributeKeys = useMemo((): QueryKeyDataSuggestionsProps[] => {
|
||||
const keys = filterKeysData?.data?.keys;
|
||||
if (!keys) {
|
||||
// by default, when QuerySearch keys fails, we don't render fallback keys
|
||||
// we just return empty to let user write whatever they want with no
|
||||
// key suggestion
|
||||
return [];
|
||||
}
|
||||
return Object.values(keys).flatMap((items) =>
|
||||
items.map(
|
||||
(item) =>
|
||||
({
|
||||
label: item.name,
|
||||
name: item.name,
|
||||
type: item.fieldDataType || 'string',
|
||||
signal: 'logs' as const,
|
||||
fieldDataType: item.fieldDataType,
|
||||
fieldContext: fieldContextToSuggestionContext(item.fieldContext),
|
||||
}) satisfies QueryKeyDataSuggestionsProps,
|
||||
),
|
||||
);
|
||||
}, [filterKeysData]);
|
||||
|
||||
const valueSuggestionsOverride = useCallback(
|
||||
async (
|
||||
key: string,
|
||||
searchText: string,
|
||||
): Promise<{
|
||||
stringValues: string[];
|
||||
numberValues: number[];
|
||||
complete: boolean;
|
||||
}> => {
|
||||
if (!ruleId) {
|
||||
return {
|
||||
stringValues: [],
|
||||
numberValues: [],
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
const response = await getRuleHistoryFilterValues(
|
||||
{ id: ruleId },
|
||||
{
|
||||
name: key,
|
||||
searchText,
|
||||
startUnixMilli: startTime,
|
||||
endUnixMilli: endTime,
|
||||
},
|
||||
);
|
||||
const values = response.data?.values;
|
||||
return {
|
||||
stringValues: values?.stringValues ?? [],
|
||||
numberValues: values?.numberValues ?? [],
|
||||
complete: response.data?.complete ?? false,
|
||||
};
|
||||
},
|
||||
[ruleId, startTime, endTime],
|
||||
);
|
||||
|
||||
return { hardcodedAttributeKeys, valueSuggestionsOverride, isLoadingKeys };
|
||||
}
|
||||
@@ -1,15 +1,83 @@
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Button, TableColumnsType as ColumnsType, Tooltip } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
import { Ellipsis, Search } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, TableColumnsType as ColumnsType } from 'antd';
|
||||
import ClientSideQBSearch, {
|
||||
AttributeKey,
|
||||
} from 'components/ClientSideQBSearch/ClientSideQBSearch';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
|
||||
import { transformKeyValuesToAttributeValuesMap } from 'container/QueryBuilder/filters/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
|
||||
import AlertLabels, {
|
||||
AlertLabelsProps,
|
||||
} from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
|
||||
import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState';
|
||||
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
const transformLabelsToQbKeys = (
|
||||
labels: AlertRuleTimelineTableResponse['labels'],
|
||||
): AttributeKey[] => Object.keys(labels).flatMap((key) => [{ key }]);
|
||||
|
||||
function LabelFilter({
|
||||
filters,
|
||||
setFilters,
|
||||
labels,
|
||||
}: {
|
||||
setFilters: (filters: TagFilter) => void;
|
||||
filters: TagFilter;
|
||||
labels: AlertLabelsProps['labels'];
|
||||
}): JSX.Element | null {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { transformedKeys, attributesMap } = useMemo(
|
||||
() => ({
|
||||
transformedKeys: transformLabelsToQbKeys(labels || {}),
|
||||
attributesMap: transformKeyValuesToAttributeValuesMap(labels),
|
||||
}),
|
||||
[labels],
|
||||
);
|
||||
|
||||
const handleSearch = (tagFilters: TagFilter): void => {
|
||||
const tagFiltersLength = tagFilters.items.length;
|
||||
|
||||
if (
|
||||
(!tagFiltersLength && (!filters || !filters.items.length)) ||
|
||||
tagFiltersLength === filters?.items.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setFilters(tagFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<ClientSideQBSearch
|
||||
onChange={handleSearch}
|
||||
filters={filters}
|
||||
className="alert-history-label-search"
|
||||
attributeKeys={transformedKeys}
|
||||
attributeValuesMap={attributesMap}
|
||||
suffixIcon={
|
||||
<Search
|
||||
size={14}
|
||||
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const timelineTableColumns = ({
|
||||
filters,
|
||||
labels,
|
||||
setFilters,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
}: {
|
||||
filters: TagFilter;
|
||||
labels: AlertLabelsProps['labels'];
|
||||
setFilters: (filters: TagFilter) => void;
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
@@ -27,7 +95,9 @@ export const timelineTableColumns = ({
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'LABELS',
|
||||
title: (
|
||||
<LabelFilter setFilters={setFilters} filters={filters} labels={labels} />
|
||||
),
|
||||
dataIndex: 'labels',
|
||||
render: (labels): JSX.Element => (
|
||||
<div className="alert-rule-labels">
|
||||
@@ -49,27 +119,15 @@ export const timelineTableColumns = ({
|
||||
title: 'ACTIONS',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (_, record): JSX.Element => {
|
||||
if (!record.relatedTracesLink && !record.relatedLogsLink) {
|
||||
return (
|
||||
<Tooltip title="No links available for this item">
|
||||
<Button type="text" ghost disabled>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConditionalAlertPopover
|
||||
relatedTracesLink={record.relatedTracesLink ?? ''}
|
||||
relatedLogsLink={record.relatedLogsLink ?? ''}
|
||||
>
|
||||
<Button type="text" ghost>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
);
|
||||
},
|
||||
render: (record): JSX.Element => (
|
||||
<ConditionalAlertPopover
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<Button type="text" ghost>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
Options,
|
||||
parseAsInteger,
|
||||
parseAsStringLiteral,
|
||||
useQueryState,
|
||||
UseQueryStateReturn,
|
||||
} from 'nuqs';
|
||||
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
|
||||
|
||||
const defaultNuqsOptions: Options = {
|
||||
history: 'push',
|
||||
};
|
||||
|
||||
export const TIMELINE_TABLE_PARAMS = {
|
||||
PAGE: 'page',
|
||||
ORDER: 'order',
|
||||
} as const;
|
||||
|
||||
const ORDER_VALUES = ['asc', 'desc'] as const;
|
||||
export type OrderDirection = (typeof ORDER_VALUES)[number];
|
||||
|
||||
export const useTimelineTablePage = (): UseQueryStateReturn<number, number> =>
|
||||
useQueryState(
|
||||
TIMELINE_TABLE_PARAMS.PAGE,
|
||||
parseAsInteger.withDefault(1).withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useTimelineTableOrder = (): UseQueryStateReturn<
|
||||
OrderDirection,
|
||||
OrderDirection
|
||||
> =>
|
||||
useQueryState(
|
||||
TIMELINE_TABLE_PARAMS.ORDER,
|
||||
parseAsStringLiteral(ORDER_VALUES)
|
||||
.withDefault('asc')
|
||||
.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export function encodeCursor(page: number, limit: number): string | undefined {
|
||||
if (page <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
const offset = (page - 1) * limit;
|
||||
// Backend uses base64.RawURLEncoding (URL-safe, no padding)
|
||||
return btoa(JSON.stringify({ offset, limit }))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function computeCursorForPage(page: number): string | undefined {
|
||||
return encodeCursor(page, TIMELINE_TABLE_PAGE_SIZE);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
|
||||
const fieldContextToSuggestionMap: Record<
|
||||
TelemetrytypesFieldContextDTO,
|
||||
QueryKeyDataSuggestionsProps['fieldContext']
|
||||
> = {
|
||||
[TelemetrytypesFieldContextDTO.resource]: 'resource',
|
||||
[TelemetrytypesFieldContextDTO.span]: 'span',
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
|
||||
// no maps for the following values on suggestion context
|
||||
[TelemetrytypesFieldContextDTO.body]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.metric]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.log]: undefined,
|
||||
[TelemetrytypesFieldContextDTO['']]: undefined,
|
||||
};
|
||||
|
||||
export function fieldContextToSuggestionContext(
|
||||
fc: TelemetrytypesFieldContextDTO | undefined,
|
||||
): QueryKeyDataSuggestionsProps['fieldContext'] {
|
||||
if (fc === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return fieldContextToSuggestionMap[fc];
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Querybuildertypesv5LabelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { Labels } from 'types/api/alerts/def';
|
||||
|
||||
export function labelsArrayToObject(
|
||||
labels: Querybuildertypesv5LabelDTO[] | null | undefined,
|
||||
): Labels {
|
||||
if (!labels) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return labels.reduce<Labels>((acc, label) => {
|
||||
const key = label.key?.name ?? '';
|
||||
const value = String(label.value ?? '');
|
||||
if (key) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
@@ -104,7 +104,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError,
|
||||
userPreferences,
|
||||
isFetchingUserPreferences,
|
||||
updateChangelog,
|
||||
toggleChangelogModal,
|
||||
showChangelogModal,
|
||||
@@ -725,12 +724,12 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set sidebar as loaded after user preferences fetch completes (success or error)
|
||||
// Set sidebar as loaded after user preferences are fetched
|
||||
useEffect(() => {
|
||||
if (!isFetchingUserPreferences) {
|
||||
if (userPreferences !== null) {
|
||||
setIsSidebarLoaded(true);
|
||||
}
|
||||
}, [isFetchingUserPreferences]);
|
||||
}, [userPreferences]);
|
||||
|
||||
// Use localStorage value as fallback until preferences are loaded
|
||||
const isSideNavPinned = isSidebarLoaded
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { sortByMeanDesc } from '../sortByMeanDesc';
|
||||
|
||||
interface Item {
|
||||
name: string;
|
||||
values: (number | null)[];
|
||||
}
|
||||
|
||||
const item = (name: string, values: (number | null)[]): Item => ({
|
||||
name,
|
||||
values,
|
||||
});
|
||||
|
||||
const sorted = (items: Item[]): string[] =>
|
||||
sortByMeanDesc(items, {
|
||||
getValues: (entry) => entry.values,
|
||||
getKey: (entry) => entry.name,
|
||||
}).map((entry) => entry.name);
|
||||
|
||||
describe('sortByMeanDesc', () => {
|
||||
it('orders items by descending mean', () => {
|
||||
expect(
|
||||
sorted([item('a', [1, 1]), item('b', [10, 20]), item('c', [5, 5])]),
|
||||
).toStrictEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('sinks items with no finite values to the bottom', () => {
|
||||
expect(
|
||||
sorted([item('a', []), item('b', [NaN, Infinity]), item('c', [1])]),
|
||||
).toStrictEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('ignores non-finite and null values when averaging', () => {
|
||||
// Mean over the finite values only is 10, so NaN must not poison it to last place.
|
||||
expect(
|
||||
sorted([item('a', [2, 2]), item('b', [10, NaN]), item('c', [4, null])]),
|
||||
).toStrictEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('produces the same order whichever order the items arrive in', () => {
|
||||
const a = item('a', [5]);
|
||||
const b = item('b', [5]);
|
||||
const c = item('c', [9]);
|
||||
|
||||
expect(sorted([a, b, c])).toStrictEqual(sorted([c, b, a]));
|
||||
expect(sorted([b, a, c])).toStrictEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('tiebreaks equal means on the key', () => {
|
||||
expect(sorted([item('b', [1]), item('a', [1])])).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('tiebreaks on the key for items that have no finite values either', () => {
|
||||
expect(sorted([item('b', []), item('a', [NaN])])).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const items = [item('a', [1]), item('b', [9])];
|
||||
|
||||
expect(sorted(items)).toStrictEqual(['b', 'a']);
|
||||
expect(items.map((entry) => entry.name)).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('returns an empty array for no items', () => {
|
||||
expect(sorted([])).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
type MeanInput = number | null | undefined;
|
||||
|
||||
function finiteMean(values: Iterable<MeanInput>): number | null {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (const value of values) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
sum += value;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count > 0 ? sum / count : null;
|
||||
}
|
||||
|
||||
export interface SortByMeanDescOptions<T> {
|
||||
/** Numeric values of an item; non-finite entries are ignored. */
|
||||
getValues: (item: T) => Iterable<MeanInput>;
|
||||
/** Stable identity of an item, used to break ties on equal means. */
|
||||
getKey: (item: T) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders series by descending mean, on a copy (V1 parity: `utils/getSortedSeriesData`). The wire
|
||||
* order can't stand in for it: the backend returns series in Go map-iteration order.
|
||||
*
|
||||
* Call this before building the uPlot config and aligned data — those two and click attribution
|
||||
* all index the list positionally, and uPlot draws a stacked bar's segments in series-index order.
|
||||
*/
|
||||
export function sortByMeanDesc<T>(
|
||||
items: T[],
|
||||
{ getValues, getKey }: SortByMeanDescOptions<T>,
|
||||
): T[] {
|
||||
return items
|
||||
.map((item) => ({ item, mean: finiteMean(getValues(item)) }))
|
||||
.sort((a, b) => {
|
||||
if (a.mean !== null && b.mean !== null && a.mean !== b.mean) {
|
||||
return b.mean - a.mean;
|
||||
}
|
||||
if ((a.mean === null) !== (b.mean === null)) {
|
||||
return a.mean === null ? 1 : -1;
|
||||
}
|
||||
return getKey(a.item).localeCompare(getKey(b.item));
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
@@ -13,15 +13,8 @@ import {
|
||||
import { AxiosError } from 'axios';
|
||||
import APIError from 'types/api/error';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import {
|
||||
QuickFilterChangeEventData,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraFilterCustomizedEvent,
|
||||
logInfraMonitoringListViewedEvent,
|
||||
} from 'constants/events';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import K8sBaseDetails, {
|
||||
K8sDetailsFilters,
|
||||
@@ -193,19 +186,6 @@ function Hosts(): JSX.Element {
|
||||
hostInitialLogTracesExpression(host),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleQuickFilterChange = useCallback(
|
||||
(data: QuickFilterChangeEventData): void => {
|
||||
logInfraFilterCustomizedEvent(
|
||||
InfraMonitoringEntity.HOSTS,
|
||||
'quick_filter',
|
||||
data.expression,
|
||||
data.filterItemKeys,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const controlListPrefix = !showFilters ? (
|
||||
<div className={styles.quickFiltersToggleContainer}>
|
||||
<Button
|
||||
@@ -219,10 +199,6 @@ function Hosts(): JSX.Element {
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
logInfraMonitoringListViewedEvent(InfraMonitoringEntity.HOSTS);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
@@ -251,7 +227,6 @@ function Hosts(): JSX.Element {
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
}}
|
||||
onQuickFilterChange={handleQuickFilterChange}
|
||||
/>
|
||||
</>
|
||||
</OverlayScrollbar>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-width: 800px;
|
||||
min-width: 595px;
|
||||
|
||||
> * {
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { logInfraFilterCustomizedEvent } from 'constants/events';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import {
|
||||
StatusFilterValue,
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringStatusFilter,
|
||||
} from 'container/InfraMonitoringK8sV2/hooks';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import styles from './StatusFilter.module.scss';
|
||||
|
||||
@@ -22,21 +19,11 @@ const statusOptions: Array<{
|
||||
function StatusFilter(): JSX.Element {
|
||||
const [statusFilter, setStatusFilter] = useInfraMonitoringStatusFilter();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
if (value !== undefined) {
|
||||
void setStatusFilter(value === 'all' ? '' : (value as StatusFilterValue));
|
||||
void setCurrentPage(1);
|
||||
|
||||
const expression =
|
||||
currentQuery.builder.queryData[0]?.filter?.expression || '';
|
||||
logInfraFilterCustomizedEvent(
|
||||
InfraMonitoringEntity.HOSTS,
|
||||
'host_status_toggle',
|
||||
expression,
|
||||
value === 'all' ? [] : ['host_status'],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||