mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-10 16:40:34 +01:00
Compare commits
9 Commits
issue_5015
...
v0.132.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fd6852fa6 | ||
|
|
b5567d1550 | ||
|
|
2017660eb8 | ||
|
|
ca5d888970 | ||
|
|
76b4d249e2 | ||
|
|
ef892f3361 | ||
|
|
9c4046d84a | ||
|
|
a91cee2b95 | ||
|
|
51036d6cc4 |
6
.github/workflows/e2eci.yaml
vendored
6
.github/workflows/e2eci.yaml
vendored
@@ -45,15 +45,9 @@ jobs:
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
SIGNOZ_BUILDX_GHA_SCOPE: signoz-e2e
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: setup-buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: expose-gha-runtime
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487
|
||||
- name: python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
|
||||
6
.github/workflows/integrationci.yaml
vendored
6
.github/workflows/integrationci.yaml
vendored
@@ -76,15 +76,9 @@ jobs:
|
||||
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-integrate')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SIGNOZ_BUILDX_GHA_SCOPE: signoz-integration
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: setup-buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: expose-gha-runtime
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487
|
||||
- name: python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
|
||||
19
Makefile
19
Makefile
@@ -34,8 +34,6 @@ DOCKER_BUILD_ARCHS_ENTERPRISE = $(addprefix docker-build-enterprise-,$(ARCHS))
|
||||
DOCKERFILE_ENTERPRISE = $(SRC)/cmd/enterprise/Dockerfile
|
||||
DOCKER_REGISTRY_ENTERPRISE ?= docker.io/signoz/signoz
|
||||
JS_BUILD_CONTEXT = $(SRC)/frontend
|
||||
DOCKER_BUILDX_PRUNE_FLAGS ?= --force
|
||||
SIGNOZ_INTEGRATION_BUILD_CACHE_DIR ?= /tmp/signoz-integration-buildx-cache
|
||||
|
||||
##############################################################
|
||||
# directories
|
||||
@@ -212,7 +210,7 @@ py-lint: ## Run ruff check across the shared tests project
|
||||
|
||||
.PHONY: py-test-setup
|
||||
py-test-setup: ## Bring up the shared SigNoz backend used by integration and e2e tests
|
||||
@cd tests && SIGNOZ_INTEGRATION_BUILD_CACHE_DIR=$(SIGNOZ_INTEGRATION_BUILD_CACHE_DIR) uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/bootstrap/setup.py::test_setup
|
||||
@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
|
||||
@@ -231,21 +229,6 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
|
||||
@find tests -type f -name "*.pyo" -delete 2>/dev/null || true
|
||||
@echo ">> python cache cleaned"
|
||||
|
||||
.PHONY: py-docker-clean
|
||||
py-docker-clean: ## Remove Docker image and build caches used by python integration tests
|
||||
@echo ">> removing SigNoz integration test image"
|
||||
@docker image rm -f signoz:integration 2>/dev/null || true
|
||||
@echo ">> removing local integration buildx cache directories"
|
||||
@rm -rf $(SIGNOZ_INTEGRATION_BUILD_CACHE_DIR) $(SIGNOZ_INTEGRATION_BUILD_CACHE_DIR)-next
|
||||
@echo ">> pruning docker buildx cache with flags: $(DOCKER_BUILDX_PRUNE_FLAGS)"
|
||||
@docker buildx prune $(DOCKER_BUILDX_PRUNE_FLAGS)
|
||||
|
||||
.PHONY: py-test-clean
|
||||
py-test-clean: ## Tear down python test stack and remove python/Docker test caches
|
||||
@$(MAKE) py-test-teardown || true
|
||||
@$(MAKE) py-clean
|
||||
@$(MAKE) py-docker-clean
|
||||
|
||||
|
||||
##############################################################
|
||||
# generate commands
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# syntax=docker/dockerfile:1.13
|
||||
|
||||
FROM golang:1.25-bookworm
|
||||
|
||||
ARG OS="linux"
|
||||
@@ -23,8 +21,7 @@ RUN set -eux; \
|
||||
|
||||
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/
|
||||
@@ -32,9 +29,7 @@ COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
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,5 +1,3 @@
|
||||
# syntax=docker/dockerfile:1.13
|
||||
|
||||
FROM node:22-bookworm AS build
|
||||
|
||||
WORKDIR /opt/
|
||||
@@ -32,8 +30,7 @@ RUN set -eux; \
|
||||
|
||||
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/
|
||||
@@ -41,9 +38,7 @@ COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
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/
|
||||
|
||||
@@ -129,7 +129,7 @@ sqlstore:
|
||||
# The timeout for the sqlite database to wait for a lock.
|
||||
busy_timeout: 10s
|
||||
# The default transaction locking behavior. Supported values: deferred, immediate, exclusive.
|
||||
transaction_mode: deferred
|
||||
transaction_mode: immediate
|
||||
|
||||
##################### APIServer #####################
|
||||
apiserver:
|
||||
|
||||
@@ -7394,9 +7394,11 @@ components:
|
||||
op:
|
||||
$ref: '#/components/schemas/RuletypesCompareOperator'
|
||||
recoveryTarget:
|
||||
format: double
|
||||
nullable: true
|
||||
type: number
|
||||
target:
|
||||
format: double
|
||||
nullable: true
|
||||
type: number
|
||||
targetUnit:
|
||||
@@ -7680,6 +7682,7 @@ components:
|
||||
selectedQueryName:
|
||||
type: string
|
||||
target:
|
||||
format: double
|
||||
nullable: true
|
||||
type: number
|
||||
targetUnit:
|
||||
@@ -18168,6 +18171,16 @@ paths:
|
||||
public dashboard. The panel is addressed by its key in spec.panels.
|
||||
operationId: GetPublicDashboardPanelQueryRangeV2
|
||||
parameters:
|
||||
- in: query
|
||||
name: startTime
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: endTime
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"crypto-js": "4.2.0",
|
||||
"d3-hierarchy": "3.1.2",
|
||||
"dayjs": "^1.10.7",
|
||||
"dompurify": "3.4.0",
|
||||
"dompurify": "3.4.11",
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
@@ -238,10 +238,18 @@
|
||||
"prismjs": "1.30.0",
|
||||
"got": "11.8.5",
|
||||
"form-data": "4.0.6",
|
||||
"brace-expansion": "^2.0.2",
|
||||
"brace-expansion": "^2.0.3",
|
||||
"on-headers": "^1.1.0",
|
||||
"js-cookie": "^3.0.7",
|
||||
"tmp": "0.2.7",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
"vite": "npm:rolldown-vite@7.3.1",
|
||||
"dompurify": "3.4.11",
|
||||
"js-yaml@3": "3.15.0",
|
||||
"js-yaml@4": "4.2.0",
|
||||
"yaml@1": "1.10.3",
|
||||
"react-router@6": "6.30.4",
|
||||
"markdown-it": "14.2.0",
|
||||
"mdast-util-to-hast@13": "13.2.1",
|
||||
"protocol-buffers-schema": "3.6.1"
|
||||
}
|
||||
}
|
||||
152
frontend/pnpm-lock.yaml
generated
152
frontend/pnpm-lock.yaml
generated
@@ -19,11 +19,19 @@ overrides:
|
||||
prismjs: 1.30.0
|
||||
got: 11.8.5
|
||||
form-data: 4.0.6
|
||||
brace-expansion: ^2.0.2
|
||||
brace-expansion: ^2.0.3
|
||||
on-headers: ^1.1.0
|
||||
js-cookie: ^3.0.7
|
||||
tmp: 0.2.7
|
||||
vite: npm:rolldown-vite@7.3.1
|
||||
dompurify: 3.4.11
|
||||
js-yaml@3: 3.15.0
|
||||
js-yaml@4: 4.2.0
|
||||
yaml@1: 1.10.3
|
||||
react-router@6: 6.30.4
|
||||
markdown-it: 14.2.0
|
||||
mdast-util-to-hast@13: 13.2.1
|
||||
protocol-buffers-schema: 3.6.1
|
||||
|
||||
importers:
|
||||
|
||||
@@ -79,7 +87,7 @@ importers:
|
||||
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@signozhq/ui':
|
||||
specifier: 0.0.23
|
||||
version: 0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
version: 0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
|
||||
'@tanstack/react-table':
|
||||
specifier: 8.21.3
|
||||
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -153,8 +161,8 @@ importers:
|
||||
specifier: ^1.10.7
|
||||
version: 1.11.20
|
||||
dompurify:
|
||||
specifier: 3.4.0
|
||||
version: 3.4.0
|
||||
specifier: 3.4.11
|
||||
version: 3.4.11
|
||||
event-source-polyfill:
|
||||
specifier: 1.0.31
|
||||
version: 1.0.31
|
||||
@@ -196,7 +204,7 @@ importers:
|
||||
version: 12.4.13(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
nuqs:
|
||||
specifier: 2.8.8
|
||||
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
|
||||
overlayscrollbars:
|
||||
specifier: ^2.8.1
|
||||
version: 2.9.2
|
||||
@@ -3040,6 +3048,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@remix-run/router@1.23.3':
|
||||
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-beta.53':
|
||||
resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -4143,8 +4155,8 @@ packages:
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
brace-expansion@2.1.1:
|
||||
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@@ -4805,14 +4817,8 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.2.4:
|
||||
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
|
||||
|
||||
dompurify@3.2.7:
|
||||
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
|
||||
|
||||
dompurify@3.4.0:
|
||||
resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
|
||||
dompurify@3.4.11:
|
||||
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
|
||||
|
||||
domutils@2.8.0:
|
||||
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
|
||||
@@ -6055,12 +6061,12 @@ packages:
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-yaml@3.14.1:
|
||||
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
|
||||
js-yaml@3.15.0:
|
||||
resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
js-yaml@4.2.0:
|
||||
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
|
||||
hasBin: true
|
||||
|
||||
jsdom@20.0.3:
|
||||
@@ -6244,8 +6250,8 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
linkify-it@5.0.2:
|
||||
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
|
||||
|
||||
lint-staged@17.0.4:
|
||||
resolution: {integrity: sha512-+rU9lSUyVOZ/hDUmRLVGzyS2v73cDdQjX+XQz1AaOdIE4RysLq0HoPW2HrrgeNCLklkhi904VBU1bmgWLHVnkA==}
|
||||
@@ -6364,8 +6370,8 @@ packages:
|
||||
mapbox-to-css-font@2.4.5:
|
||||
resolution: {integrity: sha512-VJ6nB8emkO9VODI0Fk+TQ/0zKBTqmf/Pkt8Xv0kHstoc0iXRajA00DAid4Kc3K5xeFIOoiZrVxijEzj0GLVO2w==}
|
||||
|
||||
markdown-it@14.1.1:
|
||||
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
|
||||
markdown-it@14.2.0:
|
||||
resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==}
|
||||
hasBin: true
|
||||
|
||||
markdown-table@3.0.4:
|
||||
@@ -6429,8 +6435,8 @@ packages:
|
||||
mdast-util-to-hast@12.3.0:
|
||||
resolution: {integrity: sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==}
|
||||
|
||||
mdast-util-to-hast@13.1.0:
|
||||
resolution: {integrity: sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA==}
|
||||
mdast-util-to-hast@13.2.1:
|
||||
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
|
||||
|
||||
mdast-util-to-markdown@1.5.0:
|
||||
resolution: {integrity: sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==}
|
||||
@@ -6772,7 +6778,7 @@ packages:
|
||||
'@tanstack/react-router': ^1
|
||||
next: '>=14.2.0'
|
||||
react: '>=18.2.0 || ^19.0.0-0'
|
||||
react-router: ^5 || ^6 || ^7
|
||||
react-router: 6.30.4
|
||||
react-router-dom: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
'@remix-run/react':
|
||||
@@ -6793,7 +6799,7 @@ packages:
|
||||
'@tanstack/react-router': ^1
|
||||
next: '>=14.2.0'
|
||||
react: '>=18.2.0 || ^19.0.0-0'
|
||||
react-router: ^5 || ^6 || ^7
|
||||
react-router: 6.30.4
|
||||
react-router-dom: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
'@remix-run/react':
|
||||
@@ -7174,8 +7180,8 @@ packages:
|
||||
property-information@6.3.0:
|
||||
resolution: {integrity: sha512-gVNZ74nqhRMiIUYWGQdosYetaKc83x8oT41a0LlV3AAFCAZwCpg4vmGkq8t34+cUhp3cnM4XDiU/7xlgK7HGrg==}
|
||||
|
||||
protocol-buffers-schema@3.6.0:
|
||||
resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==}
|
||||
protocol-buffers-schema@3.6.1:
|
||||
resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
@@ -7690,8 +7696,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=15'
|
||||
|
||||
react-router@6.30.3:
|
||||
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
|
||||
react-router@6.30.4:
|
||||
resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
@@ -8946,8 +8952,8 @@ packages:
|
||||
yallist@4.0.0:
|
||||
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||
|
||||
yaml@1.10.2:
|
||||
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
|
||||
yaml@1.10.3:
|
||||
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
yaml@2.8.2:
|
||||
@@ -9068,7 +9074,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@jsdevtools/ono': 7.1.3
|
||||
'@types/json-schema': 7.0.15
|
||||
js-yaml: 4.1.1
|
||||
js-yaml: 4.2.0
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
dependencies:
|
||||
@@ -10327,7 +10333,7 @@ snapshots:
|
||||
'@types/string-hash': 1.1.3
|
||||
d3-interpolate: 3.0.1
|
||||
date-fns: 4.1.0
|
||||
dompurify: 3.2.4
|
||||
dompurify: 3.4.11
|
||||
eventemitter3: 5.0.1
|
||||
fast_array_intersect: 1.1.0
|
||||
history: 4.10.1
|
||||
@@ -10478,7 +10484,7 @@ snapshots:
|
||||
camelcase: 5.3.1
|
||||
find-up: 4.1.0
|
||||
get-package-type: 0.1.0
|
||||
js-yaml: 3.14.1
|
||||
js-yaml: 3.15.0
|
||||
resolve-from: 5.0.0
|
||||
|
||||
'@istanbuljs/schema@0.1.3': {}
|
||||
@@ -11805,6 +11811,8 @@ snapshots:
|
||||
|
||||
'@remix-run/router@1.23.2': {}
|
||||
|
||||
'@remix-run/router@1.23.3': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-beta.53':
|
||||
optional: true
|
||||
|
||||
@@ -12042,7 +12050,7 @@ snapshots:
|
||||
- react-dom
|
||||
- tailwindcss
|
||||
|
||||
'@signozhq/ui@0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
|
||||
'@signozhq/ui@0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@chenglou/pretext': 0.0.5
|
||||
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -12071,7 +12079,7 @@ snapshots:
|
||||
lodash-es: 4.18.1
|
||||
motion: 11.18.2(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
next-themes: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
nuqs: 2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
nuqs: 2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-day-picker: 9.9.0(react@18.2.0)
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
@@ -13057,7 +13065,7 @@ snapshots:
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@2.0.2:
|
||||
brace-expansion@2.1.1:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -13381,14 +13389,14 @@ snapshots:
|
||||
import-fresh: 3.3.1
|
||||
parse-json: 5.2.0
|
||||
path-type: 4.0.0
|
||||
yaml: 1.10.2
|
||||
yaml: 1.10.3
|
||||
optional: true
|
||||
|
||||
cosmiconfig@9.0.1(typescript@5.9.3):
|
||||
dependencies:
|
||||
env-paths: 2.2.1
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.1.1
|
||||
js-yaml: 4.2.0
|
||||
parse-json: 5.2.0
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
@@ -13720,15 +13728,7 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.2.4:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dompurify@3.2.7:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dompurify@3.4.0:
|
||||
dompurify@3.4.11:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
@@ -14416,7 +14416,7 @@ snapshots:
|
||||
hast-util-from-parse5: 8.0.1
|
||||
hast-util-to-parse5: 8.0.0
|
||||
html-void-elements: 3.0.0
|
||||
mdast-util-to-hast: 13.1.0
|
||||
mdast-util-to-hast: 13.2.1
|
||||
parse5: 7.3.0
|
||||
unist-util-position: 5.0.0
|
||||
unist-util-visit: 5.0.0
|
||||
@@ -15342,12 +15342,12 @@ snapshots:
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@3.14.1:
|
||||
js-yaml@3.15.0:
|
||||
dependencies:
|
||||
argparse: 1.0.10
|
||||
esprima: 4.0.1
|
||||
|
||||
js-yaml@4.1.1:
|
||||
js-yaml@4.2.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
@@ -15396,7 +15396,7 @@ snapshots:
|
||||
'@types/json-schema': 7.0.15
|
||||
'@types/lodash': 4.17.24
|
||||
is-glob: 4.0.3
|
||||
js-yaml: 4.1.1
|
||||
js-yaml: 4.2.0
|
||||
lodash: 4.18.1
|
||||
minimist: 1.2.8
|
||||
prettier: 3.8.3
|
||||
@@ -15533,7 +15533,7 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
linkify-it@5.0.2:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
@@ -15654,11 +15654,11 @@ snapshots:
|
||||
|
||||
mapbox-to-css-font@2.4.5: {}
|
||||
|
||||
markdown-it@14.1.1:
|
||||
markdown-it@14.2.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 4.5.0
|
||||
linkify-it: 5.0.0
|
||||
linkify-it: 5.0.2
|
||||
mdurl: 2.0.0
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
@@ -15772,7 +15772,7 @@ snapshots:
|
||||
unist-util-position: 4.0.4
|
||||
unist-util-visit: 4.1.2
|
||||
|
||||
mdast-util-to-hast@13.1.0:
|
||||
mdast-util-to-hast@13.2.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.3
|
||||
@@ -16049,19 +16049,19 @@ snapshots:
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
brace-expansion: 2.1.1
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
brace-expansion: 2.1.1
|
||||
|
||||
minimatch@5.1.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
brace-expansion: 2.1.1
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
brace-expansion: 2.1.1
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -16075,7 +16075,7 @@ snapshots:
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
dependencies:
|
||||
dompurify: 3.2.7
|
||||
dompurify: 3.4.11
|
||||
marked: 14.0.0
|
||||
|
||||
motion-dom@11.18.1:
|
||||
@@ -16216,20 +16216,20 @@ snapshots:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0):
|
||||
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
react: 18.2.0
|
||||
optionalDependencies:
|
||||
react-router: 6.30.3(react@18.2.0)
|
||||
react-router: 6.30.4(react@18.2.0)
|
||||
react-router-dom: 5.3.4(react@18.2.0)
|
||||
|
||||
nuqs@2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0):
|
||||
nuqs@2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
react: 18.2.0
|
||||
optionalDependencies:
|
||||
react-router: 6.30.3(react@18.2.0)
|
||||
react-router: 6.30.4(react@18.2.0)
|
||||
react-router-dom: 5.3.4(react@18.2.0)
|
||||
|
||||
nwsapi@2.2.23: {}
|
||||
@@ -16336,7 +16336,7 @@ snapshots:
|
||||
find-up: 8.0.0
|
||||
fs-extra: 11.3.3
|
||||
jiti: 2.6.1
|
||||
js-yaml: 4.1.1
|
||||
js-yaml: 4.2.0
|
||||
remeda: 2.34.0
|
||||
string-argv: 0.3.2
|
||||
tsconfck: 3.1.6(typescript@5.9.3)
|
||||
@@ -16549,7 +16549,7 @@ snapshots:
|
||||
postcss-load-config@3.1.4(postcss@8.5.14)(ts-node@10.9.1(@types/node@16.18.25)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
lilconfig: 2.1.0
|
||||
yaml: 1.10.2
|
||||
yaml: 1.10.3
|
||||
optionalDependencies:
|
||||
postcss: 8.5.14
|
||||
ts-node: 10.9.1(@types/node@16.18.25)(typescript@5.9.3)
|
||||
@@ -16654,7 +16654,7 @@ snapshots:
|
||||
|
||||
property-information@6.3.0: {}
|
||||
|
||||
protocol-buffers-schema@3.6.0: {}
|
||||
protocol-buffers-schema@3.6.1: {}
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
@@ -17264,7 +17264,7 @@ snapshots:
|
||||
history: 5.3.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
react-router: 6.30.3(react@18.2.0)
|
||||
react-router: 6.30.4(react@18.2.0)
|
||||
react-router-dom: 5.3.4(react@18.2.0)
|
||||
|
||||
react-router-dom@5.3.4(react@18.2.0):
|
||||
@@ -17291,9 +17291,9 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
tiny-warning: 1.0.3
|
||||
|
||||
react-router@6.30.3(react@18.2.0):
|
||||
react-router@6.30.4(react@18.2.0):
|
||||
dependencies:
|
||||
'@remix-run/router': 1.23.2
|
||||
'@remix-run/router': 1.23.3
|
||||
react: 18.2.0
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@18.0.26)(react@18.2.0):
|
||||
@@ -17517,7 +17517,7 @@ snapshots:
|
||||
|
||||
resolve-protobuf-schema@2.1.0:
|
||||
dependencies:
|
||||
protocol-buffers-schema: 3.6.0
|
||||
protocol-buffers-schema: 3.6.1
|
||||
|
||||
resolve@1.22.11:
|
||||
dependencies:
|
||||
@@ -18209,7 +18209,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@gerrit0/mini-shiki': 3.23.0
|
||||
lunr: 2.3.9
|
||||
markdown-it: 14.1.1
|
||||
markdown-it: 14.2.0
|
||||
minimatch: 10.2.5
|
||||
typescript: 5.9.3
|
||||
yaml: 2.8.4
|
||||
@@ -18673,7 +18673,7 @@ snapshots:
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
yaml@1.10.2: {}
|
||||
yaml@1.10.3: {}
|
||||
|
||||
yaml@2.8.2: {}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
GetPublicDashboardDataV2200,
|
||||
GetPublicDashboardDataV2PathParameters,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
GetPublicDashboardPanelQueryRangeV2Params,
|
||||
GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
@@ -1912,20 +1913,25 @@ export const invalidateGetPublicDashboardDataV2 = async (
|
||||
*/
|
||||
export const getPublicDashboardPanelQueryRangeV2 = (
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
|
||||
id,
|
||||
key,
|
||||
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = (
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
) => {
|
||||
return [
|
||||
`/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
@@ -1933,6 +1939,7 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
@@ -1945,11 +1952,12 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
|
||||
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }, params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
|
||||
> = ({ signal }) =>
|
||||
getPublicDashboardPanelQueryRangeV2({ id, key }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1978,6 +1986,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
@@ -1988,6 +1997,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
|
||||
{ id, key },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -2004,10 +2014,16 @@ export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
params?: GetPublicDashboardPanelQueryRangeV2Params,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
|
||||
{
|
||||
queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey(
|
||||
{ id, key },
|
||||
params,
|
||||
),
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
@@ -8480,10 +8480,12 @@ export interface RuletypesBasicRuleThresholdDTO {
|
||||
op: RuletypesCompareOperatorDTO;
|
||||
/**
|
||||
* @type number,null
|
||||
* @format double
|
||||
*/
|
||||
recoveryTarget?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
* @format double
|
||||
*/
|
||||
target: number | null;
|
||||
/**
|
||||
@@ -8677,6 +8679,7 @@ export interface RuletypesRuleConditionDTO {
|
||||
selectedQueryName?: string;
|
||||
/**
|
||||
* @type number,null
|
||||
* @format double
|
||||
*/
|
||||
target?: number | null;
|
||||
/**
|
||||
@@ -11570,6 +11573,19 @@ export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
|
||||
id: string;
|
||||
key: string;
|
||||
};
|
||||
export type GetPublicDashboardPanelQueryRangeV2Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
startTime?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
endTime?: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Controller, useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
@@ -134,18 +134,17 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<AuthZTooltip checks={[SACreatePermission]}>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-sa-form"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create Service Account
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<AuthZButton
|
||||
checks={[SACreatePermission]}
|
||||
type="submit"
|
||||
form="create-sa-form"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create Service Account
|
||||
</AuthZButton>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ function OtherFiltersSkeleton(): JSX.Element {
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className="qf-other-filters-skeleton"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
buildSAAttachPermission,
|
||||
@@ -109,24 +109,21 @@ function KeyFormPhase({
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<AuthZTooltip
|
||||
<AuthZButton
|
||||
checks={[
|
||||
APIKeyCreatePermission,
|
||||
buildSAAttachPermission(accountId ?? ''),
|
||||
]}
|
||||
enabled={!!accountId}
|
||||
authZEnabled={!!accountId}
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create Key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
Create Key
|
||||
</AuthZButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { buildSADeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
@@ -84,20 +84,17 @@ function DeleteAccountModal(): JSX.Element {
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<AuthZTooltip
|
||||
<AuthZButton
|
||||
checks={[buildSADeletePermission(accountId ?? '')]}
|
||||
enabled={!!accountId}
|
||||
authZEnabled={!!accountId}
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
</AuthZButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
buildAPIKeyDeletePermission,
|
||||
@@ -158,38 +159,36 @@ function EditKeyForm({
|
||||
</form>
|
||||
|
||||
<div className="edit-key-modal__footer">
|
||||
<AuthZTooltip
|
||||
<AuthZButton
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(keyItem?.id ?? ''),
|
||||
buildSADetachPermission(accountId ?? ''),
|
||||
]}
|
||||
enabled={!!accountId && !!keyItem?.id}
|
||||
authZEnabled={!!accountId && !!keyItem?.id}
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={onRevokeClick}
|
||||
>
|
||||
<Button variant="link" color="destructive" onClick={onRevokeClick}>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</AuthZButton>
|
||||
<div className="edit-key-modal__footer-right">
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<AuthZTooltip
|
||||
<AuthZButton
|
||||
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
|
||||
enabled={!!accountId && !!keyItem?.id}
|
||||
authZEnabled={!!accountId && !!keyItem?.id}
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
Save Changes
|
||||
</AuthZButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { KeyRound, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton, Table, Tooltip } from 'antd';
|
||||
import { Pagination, Skeleton, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
APIKeyListPermission,
|
||||
buildAPIKeyDeletePermission,
|
||||
buildSAAttachPermission,
|
||||
buildSADetachPermission,
|
||||
@@ -24,10 +25,10 @@ interface KeysTabProps {
|
||||
keys: ServiceaccounttypesGettableFactorAPIKeyDTO[];
|
||||
isLoading: boolean;
|
||||
isDisabled?: boolean;
|
||||
canUpdate?: boolean;
|
||||
accountId?: string;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
interface BuildColumnsParams {
|
||||
@@ -113,29 +114,26 @@ function buildColumns({
|
||||
render: (_, record): JSX.Element => {
|
||||
const tooltipTitle = isDisabled ? 'Service account disabled' : 'Revoke Key';
|
||||
return (
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(record.id),
|
||||
buildSADetachPermission(accountId),
|
||||
]}
|
||||
enabled={!isDisabled && !!accountId}
|
||||
>
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onRevokeClick(record.id);
|
||||
}}
|
||||
className="keys-tab__revoke-btn"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</AuthZTooltip>
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<AuthZButton
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(record.id),
|
||||
buildSADetachPermission(accountId),
|
||||
]}
|
||||
authZEnabled={!isDisabled && !!accountId}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onRevokeClick(record.id);
|
||||
}}
|
||||
className="keys-tab__revoke-btn"
|
||||
>
|
||||
<X size={12} />
|
||||
</AuthZButton>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -149,6 +147,7 @@ function KeysTab({
|
||||
accountId = '',
|
||||
currentPage,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
}: KeysTabProps): JSX.Element {
|
||||
const [, setIsAddKeyOpen] = useQueryState(
|
||||
'add-key',
|
||||
@@ -212,21 +211,18 @@ function KeysTab({
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
<AuthZTooltip
|
||||
<AuthZButton
|
||||
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
|
||||
enabled={!isDisabled && !!accountId}
|
||||
authZEnabled={!isDisabled && !!accountId}
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsAddKeyOpen(true);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsAddKeyOpen(true);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
+ Add your first key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
+ Add your first key
|
||||
</AuthZButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -278,6 +274,24 @@ function KeysTab({
|
||||
})}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={keys.length}
|
||||
showTotal={(total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
<span className="sa-drawer__pagination-range">
|
||||
{range[0]} — {range[1]}
|
||||
</span>
|
||||
<span className="sa-drawer__pagination-total"> of {total}</span>
|
||||
</>
|
||||
)}
|
||||
showSizeChanger={false}
|
||||
hideOnSinglePage
|
||||
onChange={onPageChange}
|
||||
className="sa-drawer__keys-pagination"
|
||||
/>
|
||||
|
||||
<EditKeyModal keyItem={editKey} />
|
||||
|
||||
<RevokeKeyModal />
|
||||
@@ -285,4 +299,7 @@ function KeysTab({
|
||||
);
|
||||
}
|
||||
|
||||
export default KeysTab;
|
||||
export default withAuthZContent(KeysTab, {
|
||||
checks: [APIKeyListPermission],
|
||||
fallbackOnLoading: <Skeleton active paragraph={{ rows: 6 }} />,
|
||||
});
|
||||
|
||||
@@ -6,15 +6,20 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { buildSAUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import {
|
||||
buildSAReadPermission,
|
||||
buildSAUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import SaveErrorItem from './SaveErrorItem';
|
||||
import type { SaveError } from './utils';
|
||||
import { Skeleton } from 'antd';
|
||||
|
||||
interface OverviewTabProps {
|
||||
account: ServiceAccountRow;
|
||||
@@ -23,7 +28,6 @@ interface OverviewTabProps {
|
||||
localRoles: string[];
|
||||
onRolesChange: (v: string[]) => void;
|
||||
isDisabled: boolean;
|
||||
canUpdate?: boolean;
|
||||
availableRoles: AuthtypesRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
rolesError?: boolean;
|
||||
@@ -39,7 +43,6 @@ function OverviewTab({
|
||||
localRoles,
|
||||
onRolesChange,
|
||||
isDisabled,
|
||||
canUpdate = true,
|
||||
availableRoles,
|
||||
rolesLoading,
|
||||
rolesError,
|
||||
@@ -86,23 +89,22 @@ function OverviewTab({
|
||||
<label className="sa-drawer__label" htmlFor="sa-name">
|
||||
Name
|
||||
</label>
|
||||
{isDisabled || !canUpdate ? (
|
||||
<AuthZTooltip
|
||||
checks={[buildSAUpdatePermission(account.id)]}
|
||||
enabled={!isDisabled && !canUpdate}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<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>
|
||||
) : (
|
||||
<Input
|
||||
id="sa-name"
|
||||
value={localName}
|
||||
onChange={(e): void => onNameChange(e.target.value)}
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
<AuthZTooltip checks={[buildSAUpdatePermission(account.id)]}>
|
||||
<Input
|
||||
id="sa-name"
|
||||
value={localName}
|
||||
onChange={(e): void => onNameChange(e.target.value)}
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -220,4 +222,9 @@ function OverviewTab({
|
||||
);
|
||||
}
|
||||
|
||||
export default OverviewTab;
|
||||
export default withAuthZContent(OverviewTab, {
|
||||
checks: (props): ReturnType<typeof buildSAReadPermission>[] => [
|
||||
buildSAReadPermission(props.account.id),
|
||||
],
|
||||
fallbackOnLoading: <Skeleton active paragraph={{ rows: 6 }} />,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import {
|
||||
buildAPIKeyDeletePermission,
|
||||
buildSADetachPermission,
|
||||
@@ -45,23 +45,20 @@ export function RevokeKeyFooter({
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<AuthZTooltip
|
||||
<AuthZButton
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(keyId ?? ''),
|
||||
buildSADetachPermission(accountId ?? ''),
|
||||
]}
|
||||
enabled={!!accountId && !!keyId}
|
||||
authZEnabled={!!accountId && !!keyId}
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</AuthZButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -111,7 +108,7 @@ function RevokeKeyModal(): JSX.Element {
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
setRevokeKeyId(null);
|
||||
void setRevokeKeyId(null);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Key, LayoutGrid, Plus, Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { Skeleton } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getListServiceAccountsQueryKey,
|
||||
@@ -16,7 +22,6 @@ import {
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import {
|
||||
@@ -28,15 +33,13 @@ import {
|
||||
RoleUpdateFailure,
|
||||
useServiceAccountRoleManager,
|
||||
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
APIKeyListPermission,
|
||||
buildSAAttachPermission,
|
||||
buildSADeletePermission,
|
||||
buildSAReadPermission,
|
||||
buildSAUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
@@ -47,7 +50,6 @@ import {
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import DeleteAccountModal from './DeleteAccountModal';
|
||||
import KeysTab from './KeysTab';
|
||||
@@ -70,14 +72,12 @@ function toSaveApiError(err: unknown): APIError {
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function ServiceAccountDrawer({
|
||||
onSuccess,
|
||||
}: ServiceAccountDrawerProps): JSX.Element {
|
||||
const [selectedAccountId, setSelectedAccountId] = useQueryState(
|
||||
SA_QUERY_PARAMS.ACCOUNT,
|
||||
);
|
||||
const open = !!selectedAccountId;
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
SA_QUERY_PARAMS.TAB,
|
||||
parseAsStringEnum<ServiceAccountDrawerTab>(
|
||||
@@ -100,28 +100,14 @@ function ServiceAccountDrawer({
|
||||
SA_QUERY_PARAMS.DELETE_SA,
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
|
||||
const [localName, setLocalName] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { permissions: drawerPermissions, isLoading: isAuthZLoading } = useAuthZ(
|
||||
selectedAccountId
|
||||
? [
|
||||
buildSAReadPermission(selectedAccountId),
|
||||
buildSAUpdatePermission(selectedAccountId),
|
||||
buildSADeletePermission(selectedAccountId),
|
||||
APIKeyListPermission,
|
||||
]
|
||||
: [],
|
||||
{ enabled: !!selectedAccountId },
|
||||
);
|
||||
|
||||
const canRead =
|
||||
drawerPermissions?.[buildSAReadPermission(selectedAccountId ?? '')]
|
||||
?.isGranted ?? false;
|
||||
const open = !!selectedAccountId;
|
||||
|
||||
const {
|
||||
data: accountData,
|
||||
@@ -131,7 +117,7 @@ function ServiceAccountDrawer({
|
||||
refetch: refetchAccount,
|
||||
} = useGetServiceAccount(
|
||||
{ id: selectedAccountId ?? '' },
|
||||
{ query: { enabled: canRead && !!selectedAccountId } },
|
||||
{ query: { enabled: !!selectedAccountId } },
|
||||
);
|
||||
|
||||
const account = useMemo(
|
||||
@@ -145,7 +131,7 @@ function ServiceAccountDrawer({
|
||||
isLoading: isRolesLoading,
|
||||
applyDiff,
|
||||
} = useServiceAccountRoleManager(selectedAccountId ?? '', {
|
||||
enabled: canRead && !!selectedAccountId,
|
||||
enabled: !!selectedAccountId,
|
||||
});
|
||||
|
||||
const roleSessionRef = useRef<string | null>(null);
|
||||
@@ -194,16 +180,9 @@ function ServiceAccountDrawer({
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
const canListKeys =
|
||||
drawerPermissions?.[APIKeyListPermission]?.isGranted ?? false;
|
||||
|
||||
const canUpdate =
|
||||
drawerPermissions?.[buildSAUpdatePermission(selectedAccountId ?? '')]
|
||||
?.isGranted ?? true;
|
||||
|
||||
const { data: keysData, isLoading: keysLoading } = useListServiceAccountKeys(
|
||||
{ id: selectedAccountId ?? '' },
|
||||
{ query: { enabled: !!selectedAccountId && canListKeys } },
|
||||
{ query: { enabled: !!selectedAccountId } },
|
||||
);
|
||||
const keys = keysData?.data ?? [];
|
||||
|
||||
@@ -217,7 +196,6 @@ function ServiceAccountDrawer({
|
||||
}
|
||||
}, [keysLoading, keys.length, keysPage, setKeysPage]);
|
||||
|
||||
// the retry for this mutation is safe due to the api being idempotent on backend
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
|
||||
|
||||
const retryNameUpdate = useCallback(async (): Promise<void> => {
|
||||
@@ -375,23 +353,71 @@ function ServiceAccountDrawer({
|
||||
]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
void setIsDeleteOpen(null);
|
||||
void setIsAddKeyOpen(null);
|
||||
void setSelectedAccountId(null);
|
||||
setSaveErrors([]);
|
||||
void setActiveTab(null);
|
||||
void setKeysPage(null);
|
||||
void setEditKeyId(null);
|
||||
setSaveErrors([]);
|
||||
void setIsAddKeyOpen(null);
|
||||
void setIsDeleteOpen(null);
|
||||
void setSelectedAccountId(null);
|
||||
}, [
|
||||
setSelectedAccountId,
|
||||
setActiveTab,
|
||||
setKeysPage,
|
||||
setEditKeyId,
|
||||
setIsAddKeyOpen,
|
||||
setIsDeleteOpen,
|
||||
setSelectedAccountId,
|
||||
]);
|
||||
|
||||
const drawerContent = (
|
||||
const footer = useMemo(
|
||||
() =>
|
||||
activeTab === ServiceAccountDrawerTab.Overview && !isDeleted && open ? (
|
||||
<div className="sa-drawer__footer">
|
||||
<AuthZButton
|
||||
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
|
||||
authZEnabled={!!selectedAccountId}
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={(): void => {
|
||||
void setIsDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete Service Account
|
||||
</AuthZButton>
|
||||
<div className="sa-drawer__footer-right">
|
||||
<Button variant="outlined" color="secondary" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
<AuthZButton
|
||||
checks={[buildSAUpdatePermission(selectedAccountId ?? '')]}
|
||||
authZEnabled={!!selectedAccountId}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</AuthZButton>
|
||||
</div>
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
activeTab,
|
||||
isDeleted,
|
||||
open,
|
||||
selectedAccountId,
|
||||
isSaving,
|
||||
isDirty,
|
||||
handleClose,
|
||||
handleSave,
|
||||
setIsDeleteOpen,
|
||||
],
|
||||
);
|
||||
|
||||
const body = (
|
||||
<div className="sa-drawer__layout">
|
||||
<div className="sa-drawer__tabs">
|
||||
<ToggleGroupSimple
|
||||
@@ -433,26 +459,23 @@ function ServiceAccountDrawer({
|
||||
]}
|
||||
/>
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<AuthZTooltip
|
||||
<AuthZButton
|
||||
checks={[
|
||||
APIKeyCreatePermission,
|
||||
buildSAAttachPermission(selectedAccountId ?? ''),
|
||||
]}
|
||||
enabled={!isDeleted && !!selectedAccountId}
|
||||
authZEnabled={!isDeleted && !!selectedAccountId}
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
disabled={isDeleted}
|
||||
onClick={(): void => {
|
||||
void setIsAddKeyOpen(true);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
disabled={isDeleted}
|
||||
onClick={(): void => {
|
||||
void setIsAddKeyOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Plus size={12} />
|
||||
Add Key
|
||||
</AuthZButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -461,9 +484,7 @@ function ServiceAccountDrawer({
|
||||
activeTab === ServiceAccountDrawerTab.Keys ? ' sa-drawer__body--keys' : ''
|
||||
}`}
|
||||
>
|
||||
{(isAuthZLoading || isAccountLoading) && (
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
)}
|
||||
{isAccountLoading && <Skeleton active paragraph={{ rows: 6 }} />}
|
||||
{isAccountError && (
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
@@ -472,141 +493,73 @@ function ServiceAccountDrawer({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!isAuthZLoading &&
|
||||
!isAccountLoading &&
|
||||
!isAccountError &&
|
||||
selectedAccountId && (
|
||||
<>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview &&
|
||||
(canRead && account ? (
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={handleNameChange}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={(roles): void => {
|
||||
setLocalRoles(roles);
|
||||
clearRoleErrors();
|
||||
}}
|
||||
isDisabled={isDeleted}
|
||||
canUpdate={canUpdate}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
rolesError={rolesError}
|
||||
rolesErrorObj={rolesErrorObj}
|
||||
onRefetchRoles={refetchRoles}
|
||||
saveErrors={saveErrors}
|
||||
/>
|
||||
) : (
|
||||
<PermissionDeniedCallout permissionName="serviceaccount:read" />
|
||||
))}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys &&
|
||||
(canListKeys ? (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDeleted}
|
||||
canUpdate={canUpdate}
|
||||
accountId={selectedAccountId}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
) : (
|
||||
<PermissionDeniedCallout permissionName="factor-api-key:list" />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!isAccountLoading && !isAccountError && (
|
||||
<>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview &&
|
||||
(account ? (
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={handleNameChange}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={(roles): void => {
|
||||
setLocalRoles(roles);
|
||||
clearRoleErrors();
|
||||
}}
|
||||
isDisabled={isDeleted}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
rolesError={rolesError}
|
||||
rolesErrorObj={rolesErrorObj}
|
||||
onRefetchRoles={refetchRoles}
|
||||
saveErrors={saveErrors}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton active />
|
||||
))}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDeleted}
|
||||
accountId={selectedAccountId ?? ''}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={(page): void => {
|
||||
void setKeysPage(page);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<div className="sa-drawer__footer">
|
||||
{activeTab === ServiceAccountDrawerTab.Keys ? (
|
||||
<Pagination
|
||||
current={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={keys.length}
|
||||
showTotal={(total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
<span className="sa-drawer__pagination-range">
|
||||
{range[0]} — {range[1]}
|
||||
</span>
|
||||
<span className="sa-drawer__pagination-total"> of {total}</span>
|
||||
</>
|
||||
)}
|
||||
showSizeChanger={false}
|
||||
hideOnSinglePage
|
||||
onChange={(page): void => {
|
||||
void setKeysPage(page);
|
||||
}}
|
||||
className="sa-drawer__keys-pagination"
|
||||
/>
|
||||
) : (
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
showOverlay={false}
|
||||
title="Service Account Details"
|
||||
className="sa-drawer"
|
||||
width="wide"
|
||||
footer={footer}
|
||||
>
|
||||
{open && (
|
||||
<>
|
||||
{!isDeleted && (
|
||||
<AuthZTooltip
|
||||
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
|
||||
enabled={!!selectedAccountId}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={(): void => {
|
||||
void setIsDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete Service Account
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
{!isDeleted && (
|
||||
<div className="sa-drawer__footer-right">
|
||||
<Button variant="outlined" color="secondary" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{body}
|
||||
<DeleteAccountModal />
|
||||
<AddKeyModal />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
showOverlay={false}
|
||||
title="Service Account Details"
|
||||
className="sa-drawer"
|
||||
width="wide"
|
||||
footer={footer}
|
||||
>
|
||||
{drawerContent}
|
||||
</DrawerWrapper>
|
||||
|
||||
<DeleteAccountModal />
|
||||
|
||||
<AddKeyModal />
|
||||
</>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import {
|
||||
@@ -59,6 +60,7 @@ describe('AddKeyModal', () => {
|
||||
rest.post(SA_KEYS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(createdKeyResponse)),
|
||||
),
|
||||
setupAuthzAdmin(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
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';
|
||||
@@ -61,6 +62,7 @@ describe('EditKeyModal (URL-controlled)', () => {
|
||||
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
setupAuthzAdmin(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
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';
|
||||
@@ -35,7 +36,7 @@ const keys: ServiceaccounttypesGettableFactorAPIKeyDTO[] = [
|
||||
{
|
||||
id: 'key-2',
|
||||
name: 'Staging Key',
|
||||
expiresAt: 1924905600, // 2030-12-31
|
||||
expiresAt: 1924948800, // 2030-12-31 12:00 UTC (noon to avoid timezone issues)
|
||||
lastObservedAt: '2026-03-10T10:00:00Z',
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
@@ -47,6 +48,7 @@ const defaultProps = {
|
||||
isDisabled: false,
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
onPageChange: jest.fn(),
|
||||
};
|
||||
|
||||
function renderKeysTab(
|
||||
@@ -67,6 +69,7 @@ describe('KeysTab', () => {
|
||||
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
setupAuthzAdmin(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -74,9 +77,12 @@ describe('KeysTab', () => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
it('renders loading state', async () => {
|
||||
renderKeysTab({ isLoading: true });
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
// Wait for authz to complete, then check for skeleton
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders empty state when no keys and clicking add sets add-key param', async () => {
|
||||
@@ -91,9 +97,9 @@ describe('KeysTab', () => {
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/No keys. Start by creating one./i),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/No keys. Start by creating one./i),
|
||||
).resolves.toBeInTheDocument();
|
||||
const addBtn = screen.getByRole('button', { name: /\+ Add your first key/i });
|
||||
await user.click(addBtn);
|
||||
expect(onUrlUpdate).toHaveBeenCalledWith(
|
||||
@@ -103,10 +109,12 @@ describe('KeysTab', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders table with keys', () => {
|
||||
it('renders table with keys', async () => {
|
||||
renderKeysTab();
|
||||
|
||||
expect(screen.getByText('Production Key')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Production Key'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('Staging Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('Never')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dec 31, 2030')).toBeInTheDocument();
|
||||
@@ -122,7 +130,7 @@ describe('KeysTab', () => {
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
const row = screen.getByText('Production Key').closest('tr');
|
||||
const row = (await screen.findByText('Production Key')).closest('tr');
|
||||
if (!row) {
|
||||
throw new Error('Row not found');
|
||||
}
|
||||
@@ -146,6 +154,8 @@ describe('KeysTab', () => {
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
// Wait for authz to complete and table to render
|
||||
await screen.findByText('Production Key');
|
||||
const revokeBtns = screen
|
||||
.getAllByRole('button')
|
||||
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
|
||||
@@ -163,7 +173,8 @@ describe('KeysTab', () => {
|
||||
|
||||
renderKeysTab();
|
||||
|
||||
// Seed the keys cache so RevokeKeyModal can read the key name
|
||||
// Wait for authz to complete and table to render
|
||||
await screen.findByText('Production Key');
|
||||
const revokeBtns = screen
|
||||
.getAllByRole('button')
|
||||
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
|
||||
@@ -177,9 +188,11 @@ describe('KeysTab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('disables actions when isDisabled is true', () => {
|
||||
it('disables actions when isDisabled is true', async () => {
|
||||
renderKeysTab({ isDisabled: true });
|
||||
|
||||
// Wait for authz to complete and table to render
|
||||
await screen.findByText('Production Key');
|
||||
const revokeBtns = screen
|
||||
.getAllByRole('button')
|
||||
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
@@ -32,30 +31,6 @@ const activeAccountResponse = {
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
jest.mock('@signozhq/ui/drawer', () => ({
|
||||
...jest.requireActual('@signozhq/ui/drawer'),
|
||||
DrawerWrapper: ({
|
||||
children,
|
||||
footer,
|
||||
open,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
function renderDrawer(
|
||||
searchParams: Record<string, string> = { account: 'sa-1' },
|
||||
): ReturnType<typeof render> {
|
||||
@@ -118,7 +93,7 @@ describe('ServiceAccountDrawer — permissions', () => {
|
||||
renderDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/serviceaccount:read/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/read:serviceaccount/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,7 +115,7 @@ describe('ServiceAccountDrawer — permissions', () => {
|
||||
fireEvent.click(screen.getByRole('radio', { name: /keys/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/factor-api-key:list/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/list:factor-api-key/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
@@ -7,30 +6,6 @@ import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import ServiceAccountDrawer from '../ServiceAccountDrawer';
|
||||
|
||||
jest.mock('@signozhq/ui/drawer', () => ({
|
||||
...jest.requireActual('@signozhq/ui/drawer'),
|
||||
DrawerWrapper: ({
|
||||
children,
|
||||
footer,
|
||||
open,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
|
||||
@@ -31,12 +31,6 @@ export enum LOCALSTORAGE {
|
||||
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
|
||||
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
|
||||
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
|
||||
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
|
||||
QUICK_FILTERS_WIDTH_TRACES = 'QUICK_FILTERS_WIDTH_TRACES',
|
||||
QUICK_FILTERS_WIDTH_METER = 'QUICK_FILTERS_WIDTH_METER',
|
||||
QUICK_FILTERS_WIDTH_API_MONITORING = 'QUICK_FILTERS_WIDTH_API_MONITORING',
|
||||
QUICK_FILTERS_WIDTH_EXCEPTIONS = 'QUICK_FILTERS_WIDTH_EXCEPTIONS',
|
||||
QUICK_FILTERS_WIDTH_INFRA = 'QUICK_FILTERS_WIDTH_INFRA',
|
||||
FUNNEL_STEPS = 'FUNNEL_STEPS',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
|
||||
@@ -7,6 +7,7 @@ export const REACT_QUERY_KEY = {
|
||||
AUTO_REFRESH_QUERY: 'AUTO_REFRESH_QUERY',
|
||||
|
||||
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
|
||||
GET_PUBLIC_DASHBOARD_RESOLVED: 'GET_PUBLIC_DASHBOARD_RESOLVED',
|
||||
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
|
||||
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',
|
||||
GET_ALL_LICENCES: 'GET_ALL_LICENCES',
|
||||
|
||||
@@ -163,23 +163,12 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.api-quick-filter-left-section {
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,49 +4,21 @@ import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
|
||||
import DomainList from './Domains/DomainList';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
useEffect(() => {
|
||||
logEvent('API Monitoring: Landing page visited', {});
|
||||
}, []);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_API_MONITORING,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className={cx('api-monitoring-page', 'filter-visible')}>
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="api-quick-filter-left-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<section className="api-quick-filter-left-section">
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
@@ -55,7 +27,7 @@ function Explorer(): JSX.Element {
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</section>
|
||||
<DomainList />
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
&__title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -51,14 +51,14 @@
|
||||
|
||||
&__subtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
line-height: 20px;
|
||||
}
|
||||
@@ -67,7 +67,7 @@
|
||||
margin: 0;
|
||||
margin-top: 8px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -106,7 +106,7 @@
|
||||
border: 1px dashed var(--l1-border);
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -120,15 +120,15 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
// Stack the message and the resources card; card matches the content
|
||||
// width above it and is capped so it doesn't sprawl in a wide panel.
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
max-width: 825px;
|
||||
gap: 25px;
|
||||
justify-content: center;
|
||||
margin-left: 21px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
width: fit-content;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
@@ -142,7 +142,7 @@
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
width: 332px;
|
||||
width: 100%; // match the content width above
|
||||
}
|
||||
|
||||
&__resources-title {
|
||||
@@ -155,7 +155,6 @@
|
||||
text-transform: uppercase;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
&__resources-links {
|
||||
|
||||
@@ -6,7 +6,6 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
} from 'container/InfraMonitoringK8s/hooks';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -43,21 +40,8 @@ import { getHostsQuickFiltersConfig } from './utils';
|
||||
import styles from './InfraMonitoringHosts.module.scss';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
|
||||
@@ -155,18 +139,7 @@ function Hosts(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
<Tooltip title="Collapse Filters">
|
||||
@@ -183,7 +156,7 @@ function Hosts(): JSX.Element {
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleQuickFiltersChange}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.listContainer}${
|
||||
|
||||
@@ -44,20 +44,26 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
@@ -136,9 +142,7 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.quickFiltersToggleContainer {
|
||||
|
||||
@@ -6,7 +6,6 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import K8sBaseDetails from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
} from 'container/InfraMonitoringK8sV2/hooks';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -43,21 +40,8 @@ import { getHostsQuickFiltersConfig } from './utils';
|
||||
import styles from './InfraMonitoringHosts.module.scss';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
|
||||
@@ -155,18 +139,7 @@ function Hosts(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
<Tooltip title="Collapse Filters">
|
||||
@@ -183,7 +156,7 @@ function Hosts(): JSX.Element {
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleQuickFiltersChange}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.listContainer}${
|
||||
|
||||
@@ -44,20 +44,26 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
@@ -136,9 +142,7 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.quickFiltersToggleContainer {
|
||||
|
||||
@@ -44,15 +44,26 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
@@ -117,9 +128,7 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
@@ -210,16 +219,8 @@
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import {
|
||||
@@ -23,8 +22,6 @@ import {
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { FeatureKeys } from '../../constants/features';
|
||||
@@ -59,23 +56,9 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
|
||||
|
||||
import styles from './InfraMonitoringK8s.module.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
@@ -229,18 +212,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
@@ -293,7 +265,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
||||
@@ -44,15 +44,26 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
@@ -117,9 +128,7 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
@@ -210,16 +219,8 @@
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import {
|
||||
@@ -23,8 +22,6 @@ import {
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { FeatureKeys } from '../../constants/features';
|
||||
@@ -59,23 +56,9 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
|
||||
|
||||
import styles from './InfraMonitoringK8s.module.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
@@ -229,18 +212,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
@@ -293,7 +265,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.07px;
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
margin: 0;
|
||||
margin-bottom: var(--spacing-8);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&__controls {
|
||||
|
||||
@@ -3,23 +3,12 @@
|
||||
flex-direction: row;
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
// Width is owned by ResizableBox (inline style).
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-content-section {
|
||||
@@ -97,9 +86,7 @@
|
||||
|
||||
&.quick-filters-open {
|
||||
.meter-explorer-content-section {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
width: calc(100% - 280px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
@@ -19,8 +18,6 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Filter } from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -33,10 +30,6 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
handleRunQuery,
|
||||
@@ -62,16 +55,6 @@ function Explorer(): JSX.Element {
|
||||
|
||||
const [showQuickFilters, setShowQuickFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_METER,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
@@ -144,19 +127,10 @@ function Explorer(): JSX.Element {
|
||||
'quick-filters-open': showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
<div
|
||||
className={cx('meter-explorer-quick-filters-section', {
|
||||
hidden: !showQuickFilters,
|
||||
})}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-meter-explorer"
|
||||
@@ -168,7 +142,7 @@ function Explorer(): JSX.Element {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
|
||||
<div className="meter-explorer-content-section">
|
||||
<div className="meter-explorer-explore-content">
|
||||
|
||||
@@ -7,19 +7,48 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Skeleton } from 'antd';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useNavigationBlocker } from 'hooks/useNavigationBlocker';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
|
||||
import { RouterContext } from 'lib/authz/components/withAuthZ/withAuthZ';
|
||||
import {
|
||||
buildRoleReadPermission,
|
||||
buildRoleUpdatePermission,
|
||||
RoleCreatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import PermissionEditor from './components/PermissionEditor';
|
||||
import { useCreateEditRolePageActions } from './useCreateEditRolePageActions';
|
||||
import { useNavigationBlocker } from 'hooks/useNavigationBlocker';
|
||||
|
||||
import styles from './CreateEditRolePage.module.scss';
|
||||
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
function CreateEditRolePage(): JSX.Element {
|
||||
function authzCheckFn(
|
||||
_props: object,
|
||||
router: RouterContext,
|
||||
): BrandedPermission[] {
|
||||
const match = router.matchPath<{ roleId: string }>(ROUTES.ROLE_DETAILS);
|
||||
const roleId = match?.roleId ?? 'new';
|
||||
const roleName = router.searchParams.get('name') ?? '';
|
||||
const isCreateMode = roleId === 'new';
|
||||
|
||||
if (isCreateMode) {
|
||||
return [RoleCreatePermission];
|
||||
}
|
||||
if (roleName) {
|
||||
return [
|
||||
buildRoleReadPermission(roleName),
|
||||
buildRoleUpdatePermission(roleName),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function CreateEditRolePageContent(): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const urlQuery = useUrlQuery();
|
||||
@@ -47,9 +76,6 @@ function CreateEditRolePage(): JSX.Element {
|
||||
saveError,
|
||||
validationErrors,
|
||||
isCreateMode,
|
||||
hasRequiredPermission,
|
||||
isAuthZLoading,
|
||||
deniedPermission,
|
||||
loadError,
|
||||
} = useCreateEditRolePageActions(roleId, roleName);
|
||||
|
||||
@@ -81,10 +107,6 @@ function CreateEditRolePage(): JSX.Element {
|
||||
roleName,
|
||||
]);
|
||||
|
||||
if (!hasRequiredPermission && !isAuthZLoading) {
|
||||
return <PermissionDeniedFullPage permissionName={deniedPermission} />;
|
||||
}
|
||||
|
||||
if (!isRolesEnabled && !isFeatureGateLoading) {
|
||||
return (
|
||||
<div
|
||||
@@ -127,7 +149,7 @@ function CreateEditRolePage(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthZLoading || (isLoading && !isCreateMode) || isFeatureGateLoading) {
|
||||
if ((isLoading && !isCreateMode) || isFeatureGateLoading) {
|
||||
return (
|
||||
<div className={styles.createEditRolePage}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
@@ -195,7 +217,12 @@ function CreateEditRolePage(): JSX.Element {
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={
|
||||
isCreateMode
|
||||
? [RoleCreatePermission]
|
||||
: [buildRoleUpdatePermission(roleName)]
|
||||
}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSaveAndNavigate}
|
||||
@@ -204,7 +231,7 @@ function CreateEditRolePage(): JSX.Element {
|
||||
data-testid="save-button"
|
||||
>
|
||||
{isCreateMode ? 'Create role' : 'Save changes'}
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -290,4 +317,11 @@ function CreateEditRolePage(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateEditRolePage;
|
||||
export default withAuthZPage(CreateEditRolePageContent, {
|
||||
checks: authzCheckFn,
|
||||
fallbackOnLoading: (
|
||||
<div className={styles.createEditRolePage}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
|
||||
import {
|
||||
invalidLicense,
|
||||
mockUseAuthZGrantAll,
|
||||
setupAuthzAdmin,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
function renderCreatePage(
|
||||
@@ -71,7 +68,9 @@ describe('CreateEditRolePage - Feature Gate', () => {
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('feature-gate-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
@@ -80,7 +79,9 @@ describe('CreateEditRolePage - Feature Gate', () => {
|
||||
it('shows error when license is invalid', async () => {
|
||||
renderCreatePage({ activeLicense: invalidLicense });
|
||||
|
||||
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('feature-gate-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
@@ -92,16 +93,19 @@ describe('CreateEditRolePage - Feature Gate', () => {
|
||||
await expect(screen.findByText('Create Role')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows back button when feature disabled', () => {
|
||||
it('shows back button when feature disabled', async () => {
|
||||
renderCreatePage({ activeLicense: invalidLicense });
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('cancel-button'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('back button is enabled when feature disabled', () => {
|
||||
it('back button is enabled when feature disabled', async () => {
|
||||
renderCreatePage({ activeLicense: invalidLicense });
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
|
||||
const cancelButton = await screen.findByTestId('cancel-button');
|
||||
expect(cancelButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,7 +122,9 @@ describe('CreateEditRolePage - Feature Gate', () => {
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('feature-gate-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
@@ -127,7 +133,9 @@ describe('CreateEditRolePage - Feature Gate', () => {
|
||||
it('shows error when license is invalid', async () => {
|
||||
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });
|
||||
|
||||
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('feature-gate-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { mockUseAuthZDenyAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDenyAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
function renderCreatePage(): ReturnType<typeof render> {
|
||||
@@ -31,7 +32,7 @@ function renderCreatePage(): ReturnType<typeof render> {
|
||||
describe('CreateRolePage - AuthZ', () => {
|
||||
describe('permission denied', () => {
|
||||
it('shows PermissionDeniedFullPage when create permission denied', async () => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
renderCreatePage();
|
||||
|
||||
@@ -43,19 +44,31 @@ describe('CreateRolePage - AuthZ', () => {
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows skeleton while checking permissions', () => {
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
server.use(
|
||||
rest.post('*/api/v1/authz/check', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.delay(200),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderCreatePage();
|
||||
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
it('renders create page when create permission granted', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
renderCreatePage();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('role-name-input'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,27 +3,22 @@ import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const rolesApiBase = '*/api/v1/roles';
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
function renderCreatePage(): ReturnType<typeof render> {
|
||||
return render(
|
||||
async function renderCreatePage(): Promise<ReturnType<typeof render>> {
|
||||
const result = render(
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
@@ -35,61 +30,63 @@ function renderCreatePage(): ReturnType<typeof render> {
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles/new' },
|
||||
);
|
||||
await screen.findByTestId('create-edit-role-page');
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('CreateRolePage', () => {
|
||||
describe('initial render', () => {
|
||||
it('renders create role page with testId', () => {
|
||||
renderCreatePage();
|
||||
it('renders create role page with testId', async () => {
|
||||
await renderCreatePage();
|
||||
|
||||
expect(screen.getByTestId('create-edit-role-page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows breadcrumb with "Create role" as current page', () => {
|
||||
renderCreatePage();
|
||||
it('shows breadcrumb with "Create role" as current page', async () => {
|
||||
await renderCreatePage();
|
||||
|
||||
const page = screen.getByTestId('create-edit-role-page');
|
||||
const breadcrumbs = within(page).getAllByText('Create role');
|
||||
expect(breadcrumbs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders empty name input', () => {
|
||||
renderCreatePage();
|
||||
it('renders empty name input', async () => {
|
||||
await renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
expect(nameInput).toHaveValue('');
|
||||
});
|
||||
|
||||
it('renders empty description input', () => {
|
||||
renderCreatePage();
|
||||
it('renders empty description input', async () => {
|
||||
await renderCreatePage();
|
||||
|
||||
const descInput = screen.getByTestId('role-description-input');
|
||||
expect(descInput).toHaveValue('');
|
||||
});
|
||||
|
||||
it('name input is enabled in create mode', () => {
|
||||
renderCreatePage();
|
||||
it('name input is enabled in create mode', async () => {
|
||||
await renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
expect(nameInput).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('save button shows "Create role" text', () => {
|
||||
renderCreatePage();
|
||||
it('save button shows "Create role" text', async () => {
|
||||
await renderCreatePage();
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
expect(saveBtn).toHaveTextContent('Create role');
|
||||
});
|
||||
|
||||
it('save button is disabled when no changes', () => {
|
||||
renderCreatePage();
|
||||
it('save button is disabled when no changes', async () => {
|
||||
await renderCreatePage();
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
expect(saveBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not show unsaved indicator initially', () => {
|
||||
renderCreatePage();
|
||||
it('does not show unsaved indicator initially', async () => {
|
||||
await renderCreatePage();
|
||||
|
||||
expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -98,7 +95,7 @@ describe('CreateRolePage', () => {
|
||||
describe('form interactions', () => {
|
||||
it('enables save button when name is entered', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
await renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'test-role');
|
||||
@@ -109,7 +106,7 @@ describe('CreateRolePage', () => {
|
||||
|
||||
it('shows unsaved indicator when form modified', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
await renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'my-role');
|
||||
@@ -121,7 +118,7 @@ describe('CreateRolePage', () => {
|
||||
|
||||
it('enables save button when description is entered', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
await renderCreatePage();
|
||||
|
||||
const descInput = screen.getByTestId('role-description-input');
|
||||
await user.type(descInput, 'Some description');
|
||||
@@ -134,7 +131,7 @@ describe('CreateRolePage', () => {
|
||||
describe('cancel action', () => {
|
||||
it('navigates to roles list on cancel', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
await renderCreatePage();
|
||||
|
||||
const cancelBtn = screen.getByTestId('cancel-button');
|
||||
await user.click(cancelBtn);
|
||||
@@ -163,7 +160,7 @@ describe('CreateRolePage', () => {
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
await renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'my-custom-role');
|
||||
@@ -200,7 +197,7 @@ describe('CreateRolePage', () => {
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
await renderCreatePage();
|
||||
|
||||
const descInput = screen.getByTestId('role-description-input');
|
||||
await user.type(descInput, 'Description only');
|
||||
@@ -218,7 +215,7 @@ describe('CreateRolePage', () => {
|
||||
|
||||
it('shows error banner with "Role name is required" when saving with empty name', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
await renderCreatePage();
|
||||
|
||||
const descInput = screen.getByTestId('role-description-input');
|
||||
await user.type(descInput, 'Description only');
|
||||
@@ -237,7 +234,7 @@ describe('CreateRolePage', () => {
|
||||
|
||||
it('clears error banner when user starts typing in name field', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
await renderCreatePage();
|
||||
|
||||
const descInput = screen.getByTestId('role-description-input');
|
||||
await user.type(descInput, 'Description only');
|
||||
@@ -270,7 +267,7 @@ describe('CreateRolePage', () => {
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
await renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'duplicate-role');
|
||||
@@ -291,7 +288,7 @@ describe('CreateRolePage', () => {
|
||||
describe('validation errors', () => {
|
||||
it('shows validation error when Only Selected has no items', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
await renderCreatePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import {
|
||||
mockUseAuthZDenyAll,
|
||||
mockUseAuthZGrantByPrefix,
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDenyAll,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { buildRoleUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const EDIT_ROLE_ID = 'test-role-123';
|
||||
const EDIT_ROLE_NAME = 'test-role';
|
||||
const rolesApiBase = '*/api/v1/roles';
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
id: EDIT_ROLE_ID,
|
||||
name: EDIT_ROLE_NAME,
|
||||
description: 'Test role description',
|
||||
type: 'custom',
|
||||
transactionGroups: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
function renderEditPage(): ReturnType<typeof render> {
|
||||
@@ -37,7 +58,7 @@ function renderEditPage(): ReturnType<typeof render> {
|
||||
describe('EditRolePage - AuthZ', () => {
|
||||
describe('permission denied', () => {
|
||||
it('shows PermissionDeniedFullPage when read permission denied', async () => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
renderEditPage();
|
||||
|
||||
@@ -47,7 +68,7 @@ describe('EditRolePage - AuthZ', () => {
|
||||
});
|
||||
|
||||
it('shows PermissionDeniedFullPage when update permission denied but read granted', async () => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantByPrefix('read'));
|
||||
server.use(setupAuthzDeny(buildRoleUpdatePermission(EDIT_ROLE_NAME)));
|
||||
|
||||
renderEditPage();
|
||||
|
||||
@@ -55,36 +76,35 @@ describe('EditRolePage - AuthZ', () => {
|
||||
screen.findByText(/You are not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks both read and update permissions for edit mode', () => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
|
||||
|
||||
renderEditPage();
|
||||
|
||||
expect(mockUseAuthZ).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('read'),
|
||||
expect.stringContaining('update'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows skeleton while checking permissions', () => {
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
it('shows skeleton while checking permissions', async () => {
|
||||
server.use(
|
||||
rest.post('*/api/v1/authz/check', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.delay(200),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderEditPage();
|
||||
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
it('renders edit page when both read and update permissions granted', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await expect(
|
||||
screen.findByText(`Role - ${EDIT_ROLE_NAME}`),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,10 @@ import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
|
||||
const rolesApiBase = '*/api/v1/roles';
|
||||
|
||||
@@ -32,8 +28,8 @@ const roleWithTransactionGroups = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(
|
||||
setupAuthzAdmin(),
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(roleWithTransactionGroups)),
|
||||
),
|
||||
@@ -41,7 +37,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
@@ -116,9 +111,9 @@ describe('EditRolePage', () => {
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||
});
|
||||
await expect(
|
||||
screen.findByTestId('cancel-button'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to view page when cancel clicked in error state', async () => {
|
||||
@@ -132,10 +127,8 @@ describe('EditRolePage', () => {
|
||||
|
||||
renderEditPage();
|
||||
|
||||
await waitFor(async () => {
|
||||
const cancelButton = await screen.findByTestId('cancel-button');
|
||||
await user.click(cancelButton);
|
||||
});
|
||||
const cancelButton = await screen.findByTestId('cancel-button');
|
||||
await user.click(cancelButton);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('role-details-redirect'),
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, within } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
function renderPage(): ReturnType<typeof render> {
|
||||
@@ -37,13 +34,13 @@ function renderPage(): ReturnType<typeof render> {
|
||||
|
||||
async function switchToJsonMode(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
|
||||
const jsonRadio = await screen.findByTestId('permission-editor-mode-json');
|
||||
await user.click(jsonRadio);
|
||||
}
|
||||
|
||||
async function switchToInteractiveMode(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const interactiveRadio = screen.getByTestId(
|
||||
const interactiveRadio = await screen.findByTestId(
|
||||
'permission-editor-mode-interactive',
|
||||
);
|
||||
await user.click(interactiveRadio);
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
async function expandAllCards(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const expandButton = screen.getByTestId('expand-all-button');
|
||||
const expandButton = await screen.findByTestId('expand-all-button');
|
||||
await user.click(expandButton);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
function renderPage(): ReturnType<typeof render> {
|
||||
return render(
|
||||
async function renderPage(): Promise<ReturnType<typeof render>> {
|
||||
const result = render(
|
||||
<TooltipProvider>
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
@@ -39,18 +36,20 @@ function renderPage(): ReturnType<typeof render> {
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles/new' },
|
||||
);
|
||||
await screen.findByTestId('permission-editor');
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('PermissionEditor', () => {
|
||||
describe('mode toggle', () => {
|
||||
it('renders permission editor with testId', () => {
|
||||
renderPage();
|
||||
it('renders permission editor with testId', async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(screen.getByTestId('permission-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to interactive mode', () => {
|
||||
renderPage();
|
||||
it('defaults to interactive mode', async () => {
|
||||
await renderPage();
|
||||
|
||||
const interactiveRadio = screen.getByTestId(
|
||||
'permission-editor-mode-interactive',
|
||||
@@ -60,7 +59,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('switches to JSON mode when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
|
||||
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
|
||||
await user.click(jsonRadio);
|
||||
@@ -71,7 +70,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('switches back to interactive mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
|
||||
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
|
||||
await user.click(jsonRadio);
|
||||
@@ -87,8 +86,8 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
describe('resource cards', () => {
|
||||
it('renders all resource cards', () => {
|
||||
renderPage();
|
||||
it('renders all resource cards', async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('resource-card-factor-api-key'),
|
||||
@@ -99,8 +98,8 @@ describe('PermissionEditor', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resource cards are collapsed by default', () => {
|
||||
renderPage();
|
||||
it('resource cards are collapsed by default', async () => {
|
||||
await renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
@@ -112,7 +111,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('expands resource card when header clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
@@ -126,7 +125,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('collapses expanded resource card when header clicked again', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
@@ -140,7 +139,7 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('shows granted count in resource card header', async () => {
|
||||
renderPage();
|
||||
await renderPage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
await expect(
|
||||
@@ -151,7 +150,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
describe('action toggles', () => {
|
||||
it('renders action toggles for each available action', async () => {
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -170,7 +169,7 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('defaults all actions to None scope', async () => {
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -188,7 +187,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('changes scope to All when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -209,7 +208,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('updates granted count when scope changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -228,7 +227,7 @@ describe('PermissionEditor', () => {
|
||||
describe('Only Selected scope', () => {
|
||||
it('shows item input selector when Only Selected is chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -245,7 +244,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('adds item when typed and Enter pressed', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -263,7 +262,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('adds item when Add button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -284,7 +283,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('adds multiple items separated by comma', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -304,7 +303,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('adds multiple items separated by space', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -324,7 +323,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('does not add duplicate items', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -344,7 +343,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('removes item when X clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -367,7 +366,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('shows Add button disabled when input is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -385,7 +384,7 @@ describe('PermissionEditor', () => {
|
||||
describe('scope change confirmation dialog', () => {
|
||||
it('shows confirm dialog when leaving Only Selected with items', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -407,7 +406,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('clears items when confirmed', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -434,7 +433,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('keeps items when cancelled', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -461,7 +460,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('does not show dialog when leaving Only Selected with no items', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -480,7 +479,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
describe('verbs without Only Selected option', () => {
|
||||
it('does not show Only Selected for list verb', async () => {
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -501,8 +500,8 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
describe('collapse/expand all resources', () => {
|
||||
it('shows expand/collapse toggle group', () => {
|
||||
renderPage();
|
||||
it('shows expand/collapse toggle group', async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(screen.getByTestId('toggle-all-group')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('expand-all-button')).toBeInTheDocument();
|
||||
@@ -510,7 +509,7 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('expands all cards when expand button clicked', async () => {
|
||||
renderPage();
|
||||
await renderPage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -524,7 +523,7 @@ describe('PermissionEditor', () => {
|
||||
describe('resource card error states', () => {
|
||||
it('shows error border on collapsed card with validation error', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
@@ -554,7 +553,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('hides error border when card is expanded', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
@@ -591,7 +590,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('clears validation error when permission is changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await renderPage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
useRolePermissions,
|
||||
useUpdateRolePermissions,
|
||||
} from '../hooks/useRolePermissions';
|
||||
import { useRoleAuthZ } from '../hooks/useRoleAuthZ';
|
||||
import {
|
||||
useRoleUnsavedChanges,
|
||||
type RoleFormData,
|
||||
@@ -43,9 +42,6 @@ interface UseCreateEditRolePageCallbacksResult {
|
||||
saveError: APIError | null;
|
||||
clearSaveError: () => void;
|
||||
validationErrors: Set<string>;
|
||||
hasRequiredPermission: boolean;
|
||||
isAuthZLoading: boolean;
|
||||
deniedPermission: string;
|
||||
}
|
||||
|
||||
export function useCreateEditRolePageActions(
|
||||
@@ -55,23 +51,6 @@ export function useCreateEditRolePageActions(
|
||||
const history = useHistory();
|
||||
const isCreateMode = roleId === 'new';
|
||||
|
||||
const {
|
||||
hasCreatePermission,
|
||||
hasReadPermission,
|
||||
hasUpdatePermission,
|
||||
isAuthZLoading,
|
||||
} = useRoleAuthZ(roleName);
|
||||
|
||||
const deniedPermission = useMemo(() => {
|
||||
if (isCreateMode) {
|
||||
return 'role:create';
|
||||
}
|
||||
if (roleName) {
|
||||
return `role:${roleName}:update`;
|
||||
}
|
||||
return `role:<missing-rule-name>:update`;
|
||||
}, [isCreateMode, roleName]);
|
||||
|
||||
const [formData, setFormData] = useState<RoleFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -261,10 +240,5 @@ export function useCreateEditRolePageActions(
|
||||
saveError,
|
||||
clearSaveError,
|
||||
validationErrors,
|
||||
hasRequiredPermission: isCreateMode
|
||||
? hasCreatePermission
|
||||
: hasReadPermission && hasUpdatePermission,
|
||||
isAuthZLoading,
|
||||
deniedPermission,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import APIError from 'types/api/error';
|
||||
interface UseDeleteRoleModalProps {
|
||||
roleId?: string | null;
|
||||
isManaged: boolean;
|
||||
hasDeletePermission: boolean;
|
||||
onDeleteSuccess?: () => void;
|
||||
}
|
||||
|
||||
@@ -31,7 +30,7 @@ interface UseDeleteRoleModalResult {
|
||||
export function useDeleteRoleModal(
|
||||
props: UseDeleteRoleModalProps,
|
||||
): UseDeleteRoleModalResult {
|
||||
const { roleId, isManaged, hasDeletePermission, onDeleteSuccess } = props;
|
||||
const { roleId, isManaged, onDeleteSuccess } = props;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [deleteTargetRoleId, setDeleteTargetRoleId] = useState<string | null>(
|
||||
@@ -79,10 +78,8 @@ export function useDeleteRoleModal(
|
||||
|
||||
const isDeleteModalOpen = deleteTargetRoleId !== null;
|
||||
|
||||
const isDeleteDisabled = isManaged || !hasDeletePermission;
|
||||
const deleteDisabledReason = isManaged
|
||||
? 'Managed roles cannot be deleted'
|
||||
: 'You do not have permission to delete this role';
|
||||
const isDeleteDisabled = isManaged;
|
||||
const deleteDisabledReason = 'Managed roles cannot be deleted';
|
||||
|
||||
return {
|
||||
isDeleteModalOpen,
|
||||
|
||||
@@ -5,12 +5,11 @@ import { Pagination, Skeleton } from 'antd';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
|
||||
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { RoleType } from 'types/roles';
|
||||
@@ -24,23 +23,14 @@ type DisplayItem =
|
||||
| { type: 'section'; label: string; count?: number }
|
||||
| { type: 'role'; role: AuthtypesRoleDTO };
|
||||
|
||||
interface RolesListingTableProps {
|
||||
interface RolesListContentProps {
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
function RolesListingTable({
|
||||
searchQuery,
|
||||
}: RolesListingTableProps): JSX.Element {
|
||||
function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
|
||||
const { isRolesEnabled } = useRolesFeatureGate();
|
||||
|
||||
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
|
||||
RoleListPermission,
|
||||
]);
|
||||
const hasListPermission = listPerms?.[RoleListPermission]?.isGranted ?? false;
|
||||
|
||||
const { data, isLoading, isError, error } = useListRoles({
|
||||
query: { enabled: hasListPermission },
|
||||
});
|
||||
const { data, isLoading, isError, error } = useListRoles();
|
||||
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
|
||||
const history = useHistory();
|
||||
const urlQuery = useUrlQuery();
|
||||
@@ -155,11 +145,7 @@ function RolesListingTable({
|
||||
</>
|
||||
);
|
||||
|
||||
if (!hasListPermission && listPerms !== null) {
|
||||
return <PermissionDeniedFullPage permissionName="role:list" />;
|
||||
}
|
||||
|
||||
if (isAuthZLoading || isLoading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.rolesListingTable}>
|
||||
<Skeleton active paragraph={{ rows: 5 }} />
|
||||
@@ -281,4 +267,11 @@ function RolesListingTable({
|
||||
);
|
||||
}
|
||||
|
||||
export default RolesListingTable;
|
||||
export default withAuthZPage<RolesListContentProps>(RolesListContent, {
|
||||
checks: [RoleListPermission],
|
||||
fallbackOnLoading: (
|
||||
<div className={styles.rolesListingTable}>
|
||||
<Skeleton active paragraph={{ rows: 5 }} />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RoleCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
import {
|
||||
RoleCreatePermission,
|
||||
RoleListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
|
||||
import RolesListingTable from './RolesComponents/RolesListingTable';
|
||||
@@ -37,24 +40,25 @@ function RolesSettings(): JSX.Element {
|
||||
</div>
|
||||
<div className={styles.rolesSettingsContent}>
|
||||
<div className={styles.rolesSettingsToolbar}>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search for roles..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<AuthZTooltip checks={[RoleListPermission]}>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search for roles..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</AuthZTooltip>
|
||||
{isRolesEnabled && (
|
||||
<AuthZTooltip checks={[RoleCreatePermission]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={styles.roleSettingsToolbarButton}
|
||||
onClick={(): void => history.push(ROUTES.ROLE_CREATE)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Custom role
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<AuthZButton
|
||||
checks={[RoleCreatePermission]}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={styles.roleSettingsToolbarButton}
|
||||
onClick={(): void => history.push(ROUTES.ROLE_CREATE)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Custom role
|
||||
</AuthZButton>
|
||||
)}
|
||||
</div>
|
||||
<RolesListingTable searchQuery={searchQuery} />
|
||||
|
||||
@@ -10,11 +10,17 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { Skeleton } from 'antd';
|
||||
import { useGetRole } from 'api/generated/services/role';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import { useDeleteRoleModal } from 'container/RolesSettings/DeleteRoleModal/useDeleteRoleModal';
|
||||
import { useRoleAuthZ } from 'container/RolesSettings/hooks/useRoleAuthZ';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { transformApiToRolePermissions } from 'container/RolesSettings/hooks/useRolePermissions';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
|
||||
import { RouterContext } from 'lib/authz/components/withAuthZ/withAuthZ';
|
||||
import {
|
||||
buildRoleDeletePermission,
|
||||
buildRoleReadPermission,
|
||||
buildRoleUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
import { RoleType } from 'types/roles';
|
||||
@@ -27,7 +33,7 @@ import { useViewRolePageActions } from './useViewRolePageActions';
|
||||
|
||||
import styles from './ViewRolePage.module.scss';
|
||||
|
||||
function ViewRolePage(): JSX.Element {
|
||||
function ViewRolePageContent(): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
|
||||
const { isRolesEnabled, isLoading: isFeatureGateLoading } =
|
||||
useRolesFeatureGate();
|
||||
@@ -45,26 +51,15 @@ function ViewRolePage(): JSX.Element {
|
||||
handleTabChange,
|
||||
} = useViewRolePageActions();
|
||||
|
||||
const {
|
||||
hasReadPermission,
|
||||
readRolePermission,
|
||||
hasUpdatePermission,
|
||||
updateRolePermission,
|
||||
hasDeletePermission,
|
||||
isAuthZLoading,
|
||||
} = useRoleAuthZ(roleName);
|
||||
|
||||
const { data, isLoading, error } = useGetRole(
|
||||
{ id: roleId ?? '' },
|
||||
{ query: { enabled: !!roleId && hasReadPermission } },
|
||||
{ query: { enabled: !!roleId } },
|
||||
);
|
||||
const role = data?.data;
|
||||
const isManaged = role?.type === RoleType.MANAGED;
|
||||
|
||||
const {
|
||||
isDeleteModalOpen,
|
||||
isDeleteDisabled,
|
||||
deleteDisabledReason,
|
||||
deleteError,
|
||||
handleOpenDeleteModal,
|
||||
handleCloseDeleteModal,
|
||||
@@ -72,7 +67,6 @@ function ViewRolePage(): JSX.Element {
|
||||
} = useDeleteRoleModal({
|
||||
roleId,
|
||||
isManaged: isManaged ?? false,
|
||||
hasDeletePermission,
|
||||
onDeleteSuccess: handleCancel,
|
||||
});
|
||||
|
||||
@@ -144,12 +138,6 @@ function ViewRolePage(): JSX.Element {
|
||||
],
|
||||
);
|
||||
|
||||
if (!hasReadPermission && !isAuthZLoading) {
|
||||
return (
|
||||
<PermissionDeniedFullPage permissionName={readRolePermission.object} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRolesEnabled && !isFeatureGateLoading) {
|
||||
return (
|
||||
<div className={styles.viewRolePage} data-testid="view-role-page">
|
||||
@@ -187,7 +175,7 @@ function ViewRolePage(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthZLoading || isLoading || isFeatureGateLoading) {
|
||||
if (isLoading || isFeatureGateLoading) {
|
||||
return (
|
||||
<div className={styles.viewRolePage}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
@@ -244,47 +232,55 @@ function ViewRolePage(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className={styles.viewRolePageActions}>
|
||||
<TooltipSimple
|
||||
title={isDeleteDisabled ? deleteDisabledReason : 'Open delete modal'}
|
||||
>
|
||||
<Button
|
||||
{isManaged ? (
|
||||
<TooltipSimple title="Managed roles cannot be deleted">
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
disabled
|
||||
data-testid="delete-button"
|
||||
className={styles.deleteButton}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
<AuthZButton
|
||||
checks={[buildRoleDeletePermission(roleName)]}
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={handleOpenDeleteModal}
|
||||
disabled={isDeleteDisabled}
|
||||
data-testid="delete-button"
|
||||
className={styles.deleteButton}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</AuthZButton>
|
||||
)}
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
<TooltipSimple
|
||||
title={
|
||||
isManaged
|
||||
? 'Managed roles cannot be updated'
|
||||
: hasUpdatePermission
|
||||
? 'Open update page'
|
||||
: `You are not authorized to perform ${updateRolePermission.object}`
|
||||
}
|
||||
>
|
||||
<Button
|
||||
{isManaged ? (
|
||||
<TooltipSimple title="Managed roles cannot be updated">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled
|
||||
data-testid="save-button"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
<AuthZButton
|
||||
checks={[buildRoleUpdatePermission(roleName)]}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="save-button"
|
||||
disabled={isManaged || !hasUpdatePermission}
|
||||
onClick={handleRedirectToUpdate}
|
||||
style={
|
||||
isManaged || !hasUpdatePermission
|
||||
? { pointerEvents: 'auto' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</AuthZButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -336,4 +332,14 @@ function ViewRolePage(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewRolePage;
|
||||
export default withAuthZPage(ViewRolePageContent, {
|
||||
checks: (_props: object, router: RouterContext) => {
|
||||
const roleName = router.searchParams.get('name') ?? '';
|
||||
return roleName ? [buildRoleReadPermission(roleName)] : [];
|
||||
},
|
||||
fallbackOnLoading: (
|
||||
<div className={styles.viewRolePage}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('ViewRolePage - Actions', () => {
|
||||
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
|
||||
);
|
||||
|
||||
const cancelBtn = screen.getByTestId('cancel-button');
|
||||
const cancelBtn = await screen.findByTestId('cancel-button');
|
||||
await user.click(cancelBtn);
|
||||
|
||||
await expect(
|
||||
@@ -61,7 +61,10 @@ describe('ViewRolePage - Actions', () => {
|
||||
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
|
||||
);
|
||||
|
||||
const updateBtn = screen.getByTestId('save-button');
|
||||
const updateBtn = await screen.findByTestId('save-button');
|
||||
await waitFor(() => {
|
||||
expect(updateBtn).not.toBeDisabled();
|
||||
});
|
||||
await user.click(updateBtn);
|
||||
|
||||
await expect(
|
||||
@@ -76,7 +79,10 @@ describe('ViewRolePage - Actions', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
const deleteBtn = screen.getByTestId('delete-button');
|
||||
const deleteBtn = await screen.findByTestId('delete-button');
|
||||
await waitFor(() => {
|
||||
expect(deleteBtn).not.toBeDisabled();
|
||||
});
|
||||
await user.click(deleteBtn);
|
||||
|
||||
await expect(
|
||||
@@ -105,7 +111,11 @@ describe('ViewRolePage - Actions', () => {
|
||||
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('delete-button'));
|
||||
const deleteBtn = await screen.findByTestId('delete-button');
|
||||
await waitFor(() => {
|
||||
expect(deleteBtn).not.toBeDisabled();
|
||||
});
|
||||
await user.click(deleteBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByText(/Are you sure you want to delete the role/),
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
customRoleResponse,
|
||||
managedRoleResponse,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
mockUseAuthZDenyAll,
|
||||
mockUseAuthZGrantAll,
|
||||
mockUseAuthZGrantByPrefix,
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDenyAll,
|
||||
setupAuthzGrantByPrefix,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
@@ -25,25 +27,15 @@ import {
|
||||
mockPermissionsData,
|
||||
} from './testUtils';
|
||||
|
||||
const mockUseAuthZGrantReadDeleteDenied = mockUseAuthZGrantByPrefix(
|
||||
'read',
|
||||
'update',
|
||||
);
|
||||
const mockUseAuthZGrantReadUpdateDenied = mockUseAuthZGrantByPrefix(
|
||||
'read',
|
||||
'delete',
|
||||
);
|
||||
|
||||
describe('ViewRolePage - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('permission denied', () => {
|
||||
it('shows permission denied page when read permission denied', async () => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZDenyAll);
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
@@ -63,10 +55,8 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
});
|
||||
|
||||
describe('update button visibility', () => {
|
||||
it('enables Update button when update permission granted', () => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
it('enables Update button when update permission granted', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
@@ -92,13 +82,13 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('save-button')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('save-button'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Update button when update permission denied', () => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantReadUpdateDenied);
|
||||
it('disables Update button when update permission denied', async () => {
|
||||
server.use(setupAuthzGrantByPrefix('read', 'delete'));
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
@@ -124,13 +114,13 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('save-button')).toBeDisabled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('save-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Update button when role is managed', () => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
it('disables Update button when role is managed', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: managedRoleResponse,
|
||||
@@ -160,15 +150,15 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('save-button')).toBeDisabled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('save-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows managed role tooltip when update button hovered on managed role', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: managedRoleResponse,
|
||||
@@ -198,7 +188,7 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
},
|
||||
);
|
||||
|
||||
const updateButton = screen.getByTestId('save-button');
|
||||
const updateButton = await screen.findByTestId('save-button');
|
||||
await user.hover(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -208,12 +198,8 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows authorization tooltip when update permission denied', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantReadUpdateDenied);
|
||||
it('disables and shows denial attribute when update permission denied', async () => {
|
||||
server.use(setupAuthzGrantByPrefix('read', 'delete'));
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
@@ -239,22 +225,17 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
},
|
||||
);
|
||||
|
||||
const updateButton = screen.getByTestId('save-button');
|
||||
await user.hover(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
/You are not authorized to perform/,
|
||||
);
|
||||
const updateButton = screen.getByTestId('save-button');
|
||||
expect(updateButton).toBeDisabled();
|
||||
expect(updateButton).toHaveAttribute('data-denied-permissions');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete button visibility', () => {
|
||||
it('disables Delete button when delete permission denied', () => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantReadDeleteDenied);
|
||||
it('disables Delete button when delete permission denied', async () => {
|
||||
server.use(setupAuthzGrantByPrefix('read', 'update'));
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
@@ -280,88 +261,81 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('delete-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Delete button when delete permission granted', () => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('delete-button')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows permission denied tooltip when delete permission denied', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantReadDeleteDenied);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByTestId('delete-button');
|
||||
await user.hover(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
'You do not have permission to delete this role',
|
||||
);
|
||||
expect(screen.getByTestId('delete-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('enables Delete button when delete permission granted', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-button')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables and shows denial attribute when delete permission denied', async () => {
|
||||
server.use(setupAuthzGrantByPrefix('read', 'update'));
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof roleApi.useGetRole>);
|
||||
|
||||
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
|
||||
data: mockPermissionsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const deleteButton = screen.getByTestId('delete-button');
|
||||
expect(deleteButton).toBeDisabled();
|
||||
expect(deleteButton).toHaveAttribute('data-denied-permissions');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows managed role tooltip when role is managed', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: managedRoleResponse,
|
||||
@@ -391,7 +365,7 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
},
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByTestId('delete-button');
|
||||
const deleteButton = await screen.findByTestId('delete-button');
|
||||
await user.hover(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -404,15 +378,9 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows skeleton while checking permissions', () => {
|
||||
jest.spyOn(useAuthZModule, 'useAuthZ').mockReturnValue({
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
|
||||
);
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
|
||||
@@ -38,28 +38,32 @@ describe('ViewRolePage - Custom Role', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Update button for custom roles', () => {
|
||||
it('shows Update button for custom roles', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('save-button')).toBeInTheDocument();
|
||||
await expect(screen.findByTestId('save-button')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Cancel button', () => {
|
||||
it('shows Cancel button', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('cancel-button'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Delete button', () => {
|
||||
it('shows Delete button', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('delete-button'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders created/updated timestamps labels', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
|
||||
@@ -16,13 +16,12 @@ import {
|
||||
|
||||
describe('ViewRolePage - Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('shows fallback for missing description', async () => {
|
||||
@@ -53,7 +52,7 @@ describe('ViewRolePage - Edge Cases', () => {
|
||||
await expect(screen.findByText('Description')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback for invalid timestamps', () => {
|
||||
it('shows fallback for invalid timestamps', async () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: {
|
||||
status: 'success',
|
||||
@@ -79,11 +78,14 @@ describe('ViewRolePage - Edge Cases', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('view-role-page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('shows fallback for undefined timestamps', () => {
|
||||
it('shows fallback for undefined timestamps', async () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: {
|
||||
status: 'success',
|
||||
@@ -109,6 +111,9 @@ describe('ViewRolePage - Edge Cases', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('view-role-page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
@@ -18,16 +18,15 @@ import {
|
||||
|
||||
describe('ViewRolePage - Error State', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('displays error component when API has error but role data exists', () => {
|
||||
it('displays error component when API has error but role data exists', async () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
@@ -46,7 +45,9 @@ describe('ViewRolePage - Error State', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error state with title when API fails without role data', async () => {
|
||||
@@ -64,10 +65,12 @@ describe('ViewRolePage - Error State', () => {
|
||||
await expect(
|
||||
screen.findByText('Failed to load role'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows back button on error state', () => {
|
||||
it('shows back button on error state', async () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
@@ -79,7 +82,9 @@ describe('ViewRolePage - Error State', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('cancel-button'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to roles list when back button clicked on error state', async () => {
|
||||
@@ -105,7 +110,7 @@ describe('ViewRolePage - Error State', () => {
|
||||
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByTestId('cancel-button');
|
||||
const cancelButton = await screen.findByTestId('cancel-button');
|
||||
await user.click(cancelButton);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { defaultFeatureFlags, render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
invalidLicense,
|
||||
mockUseAuthZGrantAll,
|
||||
setupAuthzAdmin,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
@@ -17,9 +17,7 @@ import {
|
||||
|
||||
describe('ViewRolePage - Feature Gate', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: undefined,
|
||||
@@ -31,6 +29,7 @@ describe('ViewRolePage - Feature Gate', () => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('feature disabled', () => {
|
||||
@@ -46,7 +45,9 @@ describe('ViewRolePage - Feature Gate', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('feature-gate-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
@@ -58,28 +59,34 @@ describe('ViewRolePage - Feature Gate', () => {
|
||||
appContextOverrides: { activeLicense: invalidLicense },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('feature-gate-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows back button when feature disabled', () => {
|
||||
it('shows back button when feature disabled', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
appContextOverrides: { activeLicense: invalidLicense },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('cancel-button'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('back button is enabled when feature disabled', () => {
|
||||
it('back button is enabled when feature disabled', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
appContextOverrides: { activeLicense: invalidLicense },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
@@ -13,13 +13,12 @@ import {
|
||||
|
||||
describe('ViewRolePage - Loading State', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('shows skeleton while fetching role', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('ViewRolePage - Managed Role', () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('disables Delete button for managed roles', () => {
|
||||
it('disables Delete button for managed roles', async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
@@ -30,10 +30,12 @@ describe('ViewRolePage - Managed Role', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('delete-button')).toBeDisabled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Update button for managed roles', () => {
|
||||
it('disables Update button for managed roles', async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
@@ -44,10 +46,12 @@ describe('ViewRolePage - Managed Role', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('save-button')).toBeDisabled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('save-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('still shows Cancel button for managed roles', () => {
|
||||
it('still shows Cancel button for managed roles', async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ViewRolePage />
|
||||
@@ -58,6 +62,8 @@ describe('ViewRolePage - Managed Role', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('cancel-button'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, within } from 'tests/test-utils';
|
||||
|
||||
@@ -17,8 +17,15 @@ import {
|
||||
mockPermissionsData,
|
||||
} from './testUtils';
|
||||
|
||||
async function waitForPageReady(): Promise<void> {
|
||||
await expect(
|
||||
screen.findByTestId('view-role-page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
}
|
||||
|
||||
async function expandAllCards(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await waitForPageReady();
|
||||
const expandButton = screen.getByTestId('expand-all-button');
|
||||
await user.click(expandButton);
|
||||
}
|
||||
@@ -30,6 +37,7 @@ describe('ViewRolePage - Permission Overview', () => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders Transaction Groups section label', async () => {
|
||||
@@ -42,19 +50,21 @@ describe('ViewRolePage - Permission Overview', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders permission overview container', () => {
|
||||
it('renders permission overview container', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows resource permission cards', () => {
|
||||
it('shows resource permission cards', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
expect(
|
||||
screen.getByTestId('resource-section-factor-api-key'),
|
||||
).toBeInTheDocument();
|
||||
@@ -64,11 +74,12 @@ describe('ViewRolePage - Permission Overview', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays granted count for each resource', () => {
|
||||
it('displays granted count for each resource', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
expect(
|
||||
screen.getByTestId('granted-count-factor-api-key'),
|
||||
).toBeInTheDocument();
|
||||
@@ -77,16 +88,15 @@ describe('ViewRolePage - Permission Overview', () => {
|
||||
|
||||
describe('ViewRolePage - Permission Overview Loading State', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('shows skeleton when permissions are loading', () => {
|
||||
it('shows skeleton when permissions are loading', async () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
@@ -105,22 +115,22 @@ describe('ViewRolePage - Permission Overview Loading State', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
expect(screen.getByTestId('permission-overview-loading')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViewRolePage - Permission Overview Error State', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('shows error when permissions fail to load', () => {
|
||||
it('shows error when permissions fail to load', async () => {
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
@@ -139,19 +149,19 @@ describe('ViewRolePage - Permission Overview Error State', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
expect(screen.getByTestId('permission-overview-error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ViewRolePage - Scope: ALL permissions', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('shows "All" badge for actions with ALL scope', async () => {
|
||||
@@ -182,7 +192,7 @@ describe('ViewRolePage - Scope: ALL permissions', () => {
|
||||
expect(screen.getByTestId('scope-badge-create')).toHaveTextContent('All');
|
||||
});
|
||||
|
||||
it('shows full granted count when all actions are ALL', () => {
|
||||
it('shows full granted count when all actions are ALL', async () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
@@ -205,6 +215,7 @@ describe('ViewRolePage - Scope: ALL permissions', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
expect(screen.getByTestId('granted-count-role')).toHaveTextContent(
|
||||
'3 / 3 granted',
|
||||
);
|
||||
@@ -212,8 +223,13 @@ describe('ViewRolePage - Scope: ALL permissions', () => {
|
||||
});
|
||||
|
||||
describe('ViewRolePage - Scope: NONE permissions', () => {
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('shows "None" badge for actions with NONE scope', async () => {
|
||||
@@ -244,7 +260,7 @@ describe('ViewRolePage - Scope: NONE permissions', () => {
|
||||
expect(screen.getByTestId('scope-badge-delete')).toHaveTextContent('None');
|
||||
});
|
||||
|
||||
it('shows zero granted count when all actions are NONE', () => {
|
||||
it('shows zero granted count when all actions are NONE', async () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
@@ -268,6 +284,7 @@ describe('ViewRolePage - Scope: NONE permissions', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
|
||||
'0 / 4 granted',
|
||||
);
|
||||
@@ -275,8 +292,13 @@ describe('ViewRolePage - Scope: NONE permissions', () => {
|
||||
});
|
||||
|
||||
describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('shows "Only selected" badge with count', async () => {
|
||||
@@ -340,7 +362,7 @@ describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
|
||||
await expect(screen.findByText('key-def-456')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('counts ONLY_SELECTED as granted in count', () => {
|
||||
it('counts ONLY_SELECTED as granted in count', async () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
@@ -362,6 +384,7 @@ describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
expect(screen.getByTestId('granted-count-serviceaccount')).toHaveTextContent(
|
||||
'1 / 2 granted',
|
||||
);
|
||||
@@ -408,8 +431,13 @@ describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
|
||||
});
|
||||
|
||||
describe('ViewRolePage - Mixed permission scopes', () => {
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders all three scope types in single resource card', async () => {
|
||||
@@ -458,7 +486,7 @@ describe('ViewRolePage - Mixed permission scopes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders multiple resources with different scope combinations', () => {
|
||||
it('renders multiple resources with different scope combinations', async () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
@@ -502,6 +530,7 @@ describe('ViewRolePage - Mixed permission scopes', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
|
||||
'2 / 2 granted',
|
||||
);
|
||||
@@ -515,8 +544,13 @@ describe('ViewRolePage - Mixed permission scopes', () => {
|
||||
});
|
||||
|
||||
describe('ViewRolePage - Unknown resources', () => {
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders unknown resource with fallback label', async () => {
|
||||
@@ -540,6 +574,7 @@ describe('ViewRolePage - Unknown resources', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
expect(
|
||||
screen.getByTestId('resource-section-future-resource'),
|
||||
).toBeInTheDocument();
|
||||
@@ -576,7 +611,7 @@ describe('ViewRolePage - Unknown resources', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles resource with empty actions', () => {
|
||||
it('handles resource with empty actions', async () => {
|
||||
mockHooksWithPermissions({
|
||||
...mockPermissionsData,
|
||||
resources: [
|
||||
@@ -595,6 +630,7 @@ describe('ViewRolePage - Unknown resources', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
expect(
|
||||
screen.getByTestId('resource-section-empty-resource'),
|
||||
).toBeInTheDocument();
|
||||
@@ -611,13 +647,15 @@ describe('ViewRolePage - View mode toggle', () => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders Interactive/JSON toggle', () => {
|
||||
it('renders Interactive/JSON toggle', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
expect(screen.getByTestId('permission-view-mode-list')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('permission-view-mode-json')).toBeInTheDocument();
|
||||
});
|
||||
@@ -629,6 +667,7 @@ describe('ViewRolePage - View mode toggle', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
|
||||
|
||||
const jsonToggle = screen.getByTestId('permission-view-mode-json');
|
||||
@@ -645,6 +684,7 @@ describe('ViewRolePage - JSON Viewer Copy Button', () => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders copy button in JSON view', async () => {
|
||||
@@ -654,6 +694,7 @@ describe('ViewRolePage - JSON Viewer Copy Button', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
const jsonToggle = screen.getByTestId('permission-view-mode-json');
|
||||
await user.click(jsonToggle);
|
||||
|
||||
@@ -669,6 +710,7 @@ describe('ViewRolePage - JSON Viewer Copy Button', () => {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
});
|
||||
|
||||
await waitForPageReady();
|
||||
const jsonToggle = screen.getByTestId('permission-view-mode-json');
|
||||
await user.click(jsonToggle);
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import {
|
||||
CoretypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
customRoleResponse,
|
||||
managedRoleResponse,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
|
||||
|
||||
@@ -79,9 +79,7 @@ export const mockPermissionsData = {
|
||||
};
|
||||
|
||||
export function mockHooksForCustomRole(): void {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
@@ -99,9 +97,7 @@ export function mockHooksForCustomRole(): void {
|
||||
}
|
||||
|
||||
export function mockHooksWithPermissions(permissions: unknown): void {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
@@ -119,9 +115,7 @@ export function mockHooksWithPermissions(permissions: unknown): void {
|
||||
}
|
||||
|
||||
export function mockHooksForManagedRole(): void {
|
||||
jest
|
||||
.spyOn(useAuthZModule, 'useAuthZ')
|
||||
.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: managedRoleResponse,
|
||||
|
||||
@@ -11,23 +11,21 @@ import {
|
||||
userEvent,
|
||||
} from 'tests/test-utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
invalidLicense,
|
||||
mockUseAuthZGrantAll,
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
import RolesSettings from '../RolesSettings';
|
||||
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const rolesApiURL = 'http://localhost/api/v1/roles';
|
||||
|
||||
describe('RolesSettings', () => {
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(
|
||||
setupAuthzAdmin(),
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
@@ -35,7 +33,6 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
@@ -273,4 +270,18 @@ describe('RolesSettings', () => {
|
||||
// Total dashes expected: 2 (for both dates)
|
||||
expect(dashFallback.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('disables search input when user lacks list permission', async () => {
|
||||
server.use(
|
||||
setupAuthzDeny(RoleListPermission),
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText('Search for roles...');
|
||||
expect(searchInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
buildRoleDeletePermission,
|
||||
buildRoleReadPermission,
|
||||
buildRoleUpdatePermission,
|
||||
RoleCreatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
ParsedPermissionObject,
|
||||
parsePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
interface UseRoleAuthZResult {
|
||||
readRolePermission: ParsedPermissionObject;
|
||||
updateRolePermission: ParsedPermissionObject;
|
||||
deleteRolePermission: ParsedPermissionObject;
|
||||
hasCreatePermission: boolean;
|
||||
hasReadPermission: boolean;
|
||||
hasUpdatePermission: boolean;
|
||||
hasDeletePermission: boolean;
|
||||
isAuthZLoading: boolean;
|
||||
}
|
||||
|
||||
export function useRoleAuthZ(roleName: string): UseRoleAuthZResult {
|
||||
const readRolePermissionName = buildRoleReadPermission(roleName);
|
||||
const updateRolePermissionName = buildRoleUpdatePermission(roleName);
|
||||
const deleteRolePermissionName = buildRoleDeletePermission(roleName);
|
||||
|
||||
const permissionsToCheck = useMemo(() => {
|
||||
const perms = [RoleCreatePermission];
|
||||
if (roleName) {
|
||||
perms.push(
|
||||
readRolePermissionName,
|
||||
updateRolePermissionName,
|
||||
deleteRolePermissionName,
|
||||
);
|
||||
}
|
||||
return perms;
|
||||
}, [
|
||||
roleName,
|
||||
readRolePermissionName,
|
||||
updateRolePermissionName,
|
||||
deleteRolePermissionName,
|
||||
]);
|
||||
|
||||
const { permissions, isLoading: isAuthZLoading } =
|
||||
useAuthZ(permissionsToCheck);
|
||||
|
||||
const hasCreatePermission = useMemo(() => {
|
||||
if (permissions === null) {
|
||||
return false;
|
||||
}
|
||||
return permissions[RoleCreatePermission]?.isGranted ?? false;
|
||||
}, [permissions]);
|
||||
|
||||
const hasReadPermission = useMemo(() => {
|
||||
if (!roleName || permissions === null) {
|
||||
return true;
|
||||
}
|
||||
return permissions[buildRoleReadPermission(roleName)]?.isGranted ?? true;
|
||||
}, [permissions, roleName]);
|
||||
|
||||
const hasUpdatePermission = useMemo(() => {
|
||||
if (!roleName || permissions === null) {
|
||||
return true;
|
||||
}
|
||||
return permissions[buildRoleUpdatePermission(roleName)]?.isGranted ?? true;
|
||||
}, [permissions, roleName]);
|
||||
|
||||
const hasDeletePermission = useMemo(() => {
|
||||
if (!roleName || permissions === null) {
|
||||
return true;
|
||||
}
|
||||
return permissions[buildRoleDeletePermission(roleName)]?.isGranted ?? true;
|
||||
}, [permissions, roleName]);
|
||||
|
||||
return {
|
||||
readRolePermission: parsePermission(readRolePermissionName),
|
||||
updateRolePermission: parsePermission(updateRolePermissionName),
|
||||
deleteRolePermission: parsePermission(deleteRolePermissionName),
|
||||
hasCreatePermission,
|
||||
hasReadPermission,
|
||||
hasUpdatePermission,
|
||||
hasDeletePermission,
|
||||
isAuthZLoading,
|
||||
};
|
||||
}
|
||||
@@ -28,7 +28,7 @@ describe('ServiceAccountsSettings — FGA', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows PermissionDeniedFullPage when list permission is denied', async () => {
|
||||
it('shows denied callout when list permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
@@ -47,14 +47,40 @@ describe('ServiceAccountsSettings — FGA', () => {
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Uh-oh! You are not authorized'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows page header and disables search when list permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map(() => false),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Service Accounts')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search by name or email...'),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows table when list permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
@@ -78,7 +104,7 @@ describe('ServiceAccountsSettings — FGA', () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText('Uh-oh! You are not authorized'),
|
||||
screen.queryByText(/is not authorized to perform/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
&__search {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
|
||||
import { invalidateListServiceAccounts } from 'api/generated/services/serviceaccount';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import Spinner from 'components/Spinner';
|
||||
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
|
||||
import ServiceAccountsTable, {
|
||||
PAGE_SIZE,
|
||||
@@ -17,7 +20,6 @@ import {
|
||||
SACreatePermission,
|
||||
SAListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
@@ -38,6 +40,13 @@ import {
|
||||
import './ServiceAccountsSettings.styles.scss';
|
||||
|
||||
function ServiceAccountsSettings(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions: authzPermissions, isLoading: isAuthZLoading } = useAuthZ([
|
||||
SAListPermission,
|
||||
]);
|
||||
const canListServiceAccounts =
|
||||
authzPermissions?.[SAListPermission]?.isGranted ?? false;
|
||||
const [, setSelectedAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
|
||||
const [currentPage, setPage] = useQueryState(
|
||||
SA_QUERY_PARAMS.PAGE,
|
||||
parseAsInteger.withDefault(1),
|
||||
@@ -52,25 +61,19 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
FilterMode.All,
|
||||
),
|
||||
);
|
||||
const [, setSelectedAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
|
||||
const [, setIsCreateModalOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.CREATE_SA,
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
|
||||
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
|
||||
SAListPermission,
|
||||
]);
|
||||
|
||||
const hasListPermission = listPerms?.[SAListPermission]?.isGranted ?? false;
|
||||
|
||||
const {
|
||||
data: serviceAccountsData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch: handleCreateSuccess,
|
||||
} = useListServiceAccounts({ query: { enabled: hasListPermission } });
|
||||
} = useListServiceAccounts({ query: { enabled: canListServiceAccounts } });
|
||||
|
||||
const controlsDisabled = isAuthZLoading || !canListServiceAccounts;
|
||||
|
||||
const allAccounts = useMemo(
|
||||
(): ServiceAccountRow[] =>
|
||||
@@ -199,9 +202,9 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
if (options?.closeDrawer) {
|
||||
void setSelectedAccountId(null);
|
||||
}
|
||||
void handleCreateSuccess();
|
||||
void invalidateListServiceAccounts(queryClient);
|
||||
},
|
||||
[handleCreateSuccess, setSelectedAccountId],
|
||||
[queryClient, setSelectedAccountId],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -223,31 +226,32 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAuthZLoading || isLoading ? (
|
||||
<Spinner height="50vh" />
|
||||
) : !hasListPermission ? (
|
||||
<PermissionDeniedFullPage permissionName="serviceaccount:list" />
|
||||
) : (
|
||||
<div className="sa-settings__list-section">
|
||||
<div className="sa-settings__controls">
|
||||
<DropdownMenuSimple
|
||||
menu={{ items: filterMenuItems }}
|
||||
className="sa-settings-filter-dropdown"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className="sa-settings-filter-trigger"
|
||||
<div className="sa-settings__list-section">
|
||||
<div className="sa-settings__controls">
|
||||
<AuthZTooltip checks={[SAListPermission]}>
|
||||
<span>
|
||||
<DropdownMenuSimple
|
||||
menu={{ items: filterMenuItems }}
|
||||
className="sa-settings-filter-dropdown"
|
||||
>
|
||||
<span>{filterLabel}</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="sa-settings-filter-trigger__chevron"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className="sa-settings-filter-trigger"
|
||||
disabled={controlsDisabled}
|
||||
>
|
||||
<span>{filterLabel}</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="sa-settings-filter-trigger__chevron"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
</span>
|
||||
</AuthZTooltip>
|
||||
|
||||
<div className="sa-settings__search">
|
||||
<div className="sa-settings__search">
|
||||
<AuthZTooltip checks={[SAListPermission]}>
|
||||
<Input
|
||||
type="search"
|
||||
name="service-accounts-search"
|
||||
@@ -258,23 +262,25 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
void setPage(1);
|
||||
}}
|
||||
className="sa-settings-search-input"
|
||||
disabled={controlsDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AuthZTooltip checks={[SACreatePermission]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
New Service Account
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
|
||||
<AuthZButton
|
||||
checks={[SACreatePermission]}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
New Service Account
|
||||
</AuthZButton>
|
||||
</div>
|
||||
|
||||
<AuthZGuardContent checks={[SAListPermission]}>
|
||||
{isError ? (
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
@@ -289,8 +295,8 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AuthZGuardContent>
|
||||
</div>
|
||||
|
||||
<CreateServiceAccountModal />
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
@@ -14,46 +13,6 @@ const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
jest.mock('@signozhq/ui/drawer', () => ({
|
||||
...jest.requireActual('@signozhq/ui/drawer'),
|
||||
DrawerWrapper: ({
|
||||
children,
|
||||
footer,
|
||||
open,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/dialog', () => ({
|
||||
...jest.requireActual('@signozhq/ui/dialog'),
|
||||
DialogWrapper: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
DialogFooter: ({ children }: { children?: ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockServiceAccountsAPI = [
|
||||
{
|
||||
id: 'sa-1',
|
||||
@@ -173,11 +132,11 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: /View service account CI Bot/i,
|
||||
}),
|
||||
);
|
||||
const viewButton = await screen.findByRole('button', {
|
||||
name: /View service account CI Bot/i,
|
||||
});
|
||||
|
||||
fireEvent.click(viewButton);
|
||||
|
||||
await expect(
|
||||
screen.findByRole('button', { name: /Delete Service Account/i }),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
|
||||
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import {
|
||||
PublicDashboardSchema,
|
||||
useGetResolvedPublicDashboard,
|
||||
} from '../useGetResolvedPublicDashboard';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
getPublicDashboardDataV2: jest.fn(),
|
||||
}));
|
||||
jest.mock('api/dashboard/public/getPublicDashboardData', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockV2 = getPublicDashboardDataV2 as jest.Mock;
|
||||
const mockV1 = getPublicDashboardDataAPI as jest.Mock;
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
|
||||
// A schema mismatch on the v2 endpoint surfaces as an AxiosError with HTTP 501 and this
|
||||
// error code; anything else must NOT trigger the v1 fallback.
|
||||
const schemaMismatchError = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 501,
|
||||
data: { error: { code: 'dashboard_invalid_data', message: 'not in v6' } },
|
||||
},
|
||||
};
|
||||
|
||||
describe('useGetResolvedPublicDashboard', () => {
|
||||
beforeEach(() => {
|
||||
mockV2.mockReset();
|
||||
mockV1.mockReset();
|
||||
});
|
||||
|
||||
it('returns the v2 model when the v2 endpoint succeeds and never calls v1', async () => {
|
||||
mockV2.mockResolvedValue({ status: 'success', data: { dashboard: {} } });
|
||||
|
||||
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-1'), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V2);
|
||||
expect(mockV2).toHaveBeenCalledWith({ id: 'id-1' });
|
||||
expect(mockV1).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to v1 when the v2 endpoint reports a schema mismatch', async () => {
|
||||
mockV2.mockRejectedValue(schemaMismatchError);
|
||||
mockV1.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { dashboard: { data: { title: 'v1' } } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-2'), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V1);
|
||||
expect(mockV1).toHaveBeenCalledWith({ id: 'id-2' });
|
||||
});
|
||||
|
||||
it('surfaces a non-schema-mismatch v2 error without falling back to v1', async () => {
|
||||
mockV2.mockRejectedValue({
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { error: { code: 'internal' } } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-3'), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(mockV1).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch without an id', () => {
|
||||
renderHook(() => useGetResolvedPublicDashboard(''), { wrapper });
|
||||
expect(mockV2).not.toHaveBeenCalled();
|
||||
expect(mockV1).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
|
||||
import { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
|
||||
import { AxiosError, isAxiosError } from 'axios';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import { PublicDashboardDataProps } from 'types/api/dashboard/public/get';
|
||||
|
||||
export enum PublicDashboardSchema {
|
||||
V1 = 'v1',
|
||||
V2 = 'v2',
|
||||
}
|
||||
|
||||
export type ResolvedPublicDashboard =
|
||||
| {
|
||||
schema: PublicDashboardSchema.V2;
|
||||
data: DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
}
|
||||
| { schema: PublicDashboardSchema.V1; data: PublicDashboardDataProps };
|
||||
|
||||
// The v2 endpoint rejects non-v6 rows with this code — our signal that it's a v1 dashboard.
|
||||
const V2_SCHEMA_MISMATCH_CODE = 'dashboard_invalid_data';
|
||||
|
||||
function isV2SchemaMismatch(error: unknown): boolean {
|
||||
if (!isAxiosError(error)) {
|
||||
return false;
|
||||
}
|
||||
const { response } = error as AxiosError<ErrorV2Resp>;
|
||||
return response?.data?.error?.code === V2_SCHEMA_MISMATCH_CODE;
|
||||
}
|
||||
|
||||
// Probe v2 first, fall back to v1 only on a schema mismatch. v1-first is unsafe: it 200s for a
|
||||
// v2 dashboard with queries un-redacted. Other v2 errors re-throw rather than mis-render as v1.
|
||||
async function resolvePublicDashboard(
|
||||
id: string,
|
||||
): Promise<ResolvedPublicDashboard> {
|
||||
try {
|
||||
const v2 = await getPublicDashboardDataV2({ id });
|
||||
return { schema: PublicDashboardSchema.V2, data: v2.data };
|
||||
} catch (error) {
|
||||
if (!isV2SchemaMismatch(error)) {
|
||||
throw error;
|
||||
}
|
||||
const v1 = await getPublicDashboardDataAPI({ id });
|
||||
return { schema: PublicDashboardSchema.V1, data: v1.data };
|
||||
}
|
||||
}
|
||||
|
||||
export const useGetResolvedPublicDashboard = (
|
||||
id: string,
|
||||
): UseQueryResult<ResolvedPublicDashboard, Error> =>
|
||||
useQuery<ResolvedPublicDashboard, Error>({
|
||||
queryFn: () => resolvePublicDashboard(id),
|
||||
queryKey: [REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_RESOLVED, id],
|
||||
enabled: !!id,
|
||||
});
|
||||
@@ -20,23 +20,12 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.all-errors-quick-filter-section {
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,18 +18,12 @@ import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import history from 'lib/history';
|
||||
import { isNull } from 'lodash-es';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
|
||||
import { routes } from './config';
|
||||
import { useAllErrorsQueryState } from './QueryStateContext';
|
||||
|
||||
import './AllErrors.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function AllErrors(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
@@ -61,38 +55,17 @@ function AllErrors(): JSX.Element {
|
||||
setShowFilters((prev) => !prev);
|
||||
};
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_EXCEPTIONS,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="all-errors-quick-filter-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<section className={cx('all-errors-quick-filter-section')}>
|
||||
<QuickFilters
|
||||
className="qf-exceptions"
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
signal={SignalType.EXCEPTIONS}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</section>
|
||||
)}
|
||||
<section
|
||||
className={cx(
|
||||
|
||||
@@ -20,7 +20,10 @@ export interface UseGetQueryRangeV5Args {
|
||||
* The retry callback gets the raw AxiosError this path rejects with (not yet normalized to
|
||||
* APIError — that happens later at the display boundary), so inspect it at the axios level.
|
||||
*/
|
||||
function retryUnlessClientError(failureCount: number, error: Error): boolean {
|
||||
export function retryUnlessClientError(
|
||||
failureCount: number,
|
||||
error: Error,
|
||||
): boolean {
|
||||
if (isAxiosError(error)) {
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
|
||||
@@ -41,18 +41,15 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.log-quick-filter-left-section {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
@@ -61,9 +58,7 @@
|
||||
}
|
||||
|
||||
.log-module-right-section {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ import {
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { defaultTo, isEmpty, isNull } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { EventSourceProvider } from 'providers/EventSource';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -46,23 +44,9 @@ import { ExplorerViews } from './utils';
|
||||
|
||||
import './LogsExplorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function LogsExplorer(): JSX.Element {
|
||||
const [showLiveLogs, setShowLiveLogs] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
@@ -242,25 +226,14 @@ function LogsExplorer(): JSX.Element {
|
||||
className={cx('logs-module-page', showFilters ? 'filter-visible' : '')}
|
||||
>
|
||||
{showFilters && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="log-quick-filter-left-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<section className={cx('log-quick-filter-left-section')}>
|
||||
<QuickFilters
|
||||
className="qf-logs-explorer"
|
||||
signal={SignalType.LOGS}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</section>
|
||||
)}
|
||||
<section className={cx('log-module-right-section')}>
|
||||
<Toolbar
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Mirrors `.refresh-actions` from DateTimeSelectionV2: one bordered, rounded container
|
||||
// holding borderless buttons split by a divider.
|
||||
.refreshActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
.refreshButton {
|
||||
display: flex;
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
:global(.ant-btn) {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
align-items: center;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Check, ChevronDown, RefreshCw } from '@signozhq/icons';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button, Popover } from 'antd';
|
||||
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import styles from './PublicAutoRefresh.module.scss';
|
||||
|
||||
// Reuse the app-wide auto-refresh popover styling.
|
||||
import 'container/TopNav/AutoRefreshV2/AutoRefreshV2.styles.scss';
|
||||
|
||||
interface PublicAutoRefreshProps {
|
||||
enabled: boolean;
|
||||
/** Selected interval key, e.g. `30s`. */
|
||||
interval: string;
|
||||
disabled?: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onIntervalChange: (key: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
// Prop-driven mirror of the app's refresh + auto-refresh cluster (the public viewer owns its
|
||||
// own time window, not Redux global time).
|
||||
function PublicAutoRefresh({
|
||||
enabled,
|
||||
interval,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
onIntervalChange,
|
||||
onRefresh,
|
||||
}: PublicAutoRefreshProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.refreshActions}>
|
||||
<div className={styles.refreshButton}>
|
||||
<Button
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={onRefresh}
|
||||
title="Refresh"
|
||||
data-testid="public-dashboard-refresh"
|
||||
/>
|
||||
</div>
|
||||
<Popover
|
||||
getPopupContainer={popupContainer}
|
||||
placement="bottomRight"
|
||||
rootClassName="auto-refresh-root"
|
||||
trigger={['click']}
|
||||
content={
|
||||
<div className="auto-refresh-menu">
|
||||
<Checkbox
|
||||
onChange={(value): void => onToggle(value === true)}
|
||||
value={enabled}
|
||||
disabled={disabled}
|
||||
className="auto-refresh-checkbox"
|
||||
>
|
||||
Auto Refresh
|
||||
</Checkbox>
|
||||
<Typography.Text disabled={disabled} className="refresh-interval-text">
|
||||
Refresh Interval
|
||||
</Typography.Text>
|
||||
{refreshIntervalOptions
|
||||
.filter((option) => option.label !== 'off')
|
||||
.map((option) => (
|
||||
<Button
|
||||
type="text"
|
||||
className="refresh-interval-btns"
|
||||
key={option.label + option.value}
|
||||
onClick={(): void => onIntervalChange(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
{option.key === interval && enabled && <Check size={14} />}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
title="Set auto refresh"
|
||||
data-testid="public-dashboard-auto-refresh"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicAutoRefresh;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import PublicAutoRefresh from '../PublicAutoRefresh';
|
||||
|
||||
const props = {
|
||||
enabled: false,
|
||||
interval: '30s',
|
||||
onToggle: jest.fn(),
|
||||
onIntervalChange: jest.fn(),
|
||||
onRefresh: jest.fn(),
|
||||
};
|
||||
|
||||
describe('PublicAutoRefresh', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the refresh and auto-refresh controls', () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
expect(screen.getByTestId('public-dashboard-refresh')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('public-dashboard-auto-refresh'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRefresh when the refresh button is clicked', async () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
await userEvent.click(screen.getByTestId('public-dashboard-refresh'));
|
||||
expect(props.onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('changes the interval from the menu', async () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
await userEvent.click(screen.getByTestId('public-dashboard-auto-refresh'));
|
||||
await userEvent.click(await screen.findByText('5 seconds'));
|
||||
expect(props.onIntervalChange).toHaveBeenCalledWith('5s');
|
||||
});
|
||||
|
||||
it('toggles auto-refresh from the menu', async () => {
|
||||
render(<PublicAutoRefresh {...props} />);
|
||||
await userEvent.click(screen.getByTestId('public-dashboard-auto-refresh'));
|
||||
await userEvent.click(await screen.findByRole('checkbox'));
|
||||
expect(props.onToggle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--l0-background);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brandLogo {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
padding: 8px 4px 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { layoutsToSections } from 'pages/DashboardPageV2/DashboardContainer/utils';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useInterval } from 'react-use';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import PublicAutoRefresh from './PublicAutoRefresh/PublicAutoRefresh';
|
||||
import PublicSectionGrid from './PublicSectionGrid/PublicSectionGrid';
|
||||
import { getStartTimeAndEndTimeFromTimeRange } from './utils';
|
||||
import styles from './PublicDashboardV2.module.scss';
|
||||
|
||||
interface PublicDashboardV2Props {
|
||||
publicDashboardId: string;
|
||||
data: DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
}
|
||||
|
||||
// Read-only viewer for a v2 (Perses-spec) public dashboard; reuses the V2 panel renderers.
|
||||
// Variables aren't rendered — the public endpoint doesn't substitute them.
|
||||
function PublicDashboardV2({
|
||||
publicDashboardId,
|
||||
data,
|
||||
}: PublicDashboardV2Props): JSX.Element {
|
||||
const { dashboard, publicDashboard } = data;
|
||||
|
||||
const sections = useMemo(
|
||||
() => layoutsToSections(dashboard?.spec?.layouts, dashboard?.spec?.panels),
|
||||
[dashboard?.spec?.layouts, dashboard?.spec?.panels],
|
||||
);
|
||||
|
||||
const [selectedTimeRangeLabel, setSelectedTimeRangeLabel] = useState<string>(
|
||||
publicDashboard?.defaultTimeRange || DEFAULT_TIME_RANGE,
|
||||
);
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState(() =>
|
||||
getStartTimeAndEndTimeFromTimeRange(
|
||||
publicDashboard?.defaultTimeRange || DEFAULT_TIME_RANGE,
|
||||
),
|
||||
);
|
||||
|
||||
const isTimeRangeEnabled = publicDashboard?.timeRangeEnabled || false;
|
||||
|
||||
const handleTimeChange = (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
): void => {
|
||||
if (dateTimeRange) {
|
||||
setSelectedTimeRange({
|
||||
startTime: Math.floor(dateTimeRange[0] / 1000),
|
||||
endTime: Math.floor(dateTimeRange[1] / 1000),
|
||||
});
|
||||
} else if (interval !== 'custom') {
|
||||
const { maxTime, minTime } = GetMinMax(interval);
|
||||
setSelectedTimeRange({
|
||||
startTime: Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000),
|
||||
endTime: Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000),
|
||||
});
|
||||
}
|
||||
setSelectedTimeRangeLabel(interval as string);
|
||||
};
|
||||
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState<boolean>(false);
|
||||
const [autoRefreshInterval, setAutoRefreshInterval] = useState<string>('30s');
|
||||
|
||||
// Auto-refresh applies only to a rolling relative range, not a fixed custom window.
|
||||
const isAutoRefreshPaused = selectedTimeRangeLabel === 'custom';
|
||||
const refreshIntervalMs = useMemo(
|
||||
() =>
|
||||
autoRefreshEnabled
|
||||
? refreshIntervalOptions.find(
|
||||
(option) => option.key === autoRefreshInterval,
|
||||
)?.value || 0
|
||||
: 0,
|
||||
[autoRefreshEnabled, autoRefreshInterval],
|
||||
);
|
||||
|
||||
useInterval(
|
||||
() => handleTimeChange(selectedTimeRangeLabel as Time),
|
||||
isAutoRefreshPaused || refreshIntervalMs === 0 ? null : refreshIntervalMs,
|
||||
);
|
||||
|
||||
const handleRefresh = (): void =>
|
||||
handleTimeChange(selectedTimeRangeLabel as Time);
|
||||
|
||||
const startMs = selectedTimeRange.startTime * 1000;
|
||||
const endMs = selectedTimeRange.endTime * 1000;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<div className={styles.brand}>
|
||||
<img src={signozBrandLogoUrl} alt="SigNoz" className={styles.brandLogo} />
|
||||
<Typography className={styles.brandName}>SigNoz</Typography>
|
||||
</div>
|
||||
<Typography.Text className={styles.title}>
|
||||
{dashboard?.spec?.display?.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{isTimeRangeEnabled && (
|
||||
<div className={styles.headerRight}>
|
||||
<PublicAutoRefresh
|
||||
enabled={autoRefreshEnabled}
|
||||
interval={autoRefreshInterval}
|
||||
disabled={isAutoRefreshPaused}
|
||||
onToggle={setAutoRefreshEnabled}
|
||||
onIntervalChange={setAutoRefreshInterval}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime={publicDashboard?.defaultTimeRange as Time}
|
||||
isModalTimeSelection
|
||||
modalSelectedInterval={selectedTimeRangeLabel as Time}
|
||||
disableUrlSync
|
||||
showRecentlyUsed={false}
|
||||
modalInitialStartTime={startMs}
|
||||
modalInitialEndTime={endMs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{sections.map((section) => (
|
||||
<section key={section.id} className={styles.section}>
|
||||
{section.title && (
|
||||
<Typography.Text className={styles.sectionTitle}>
|
||||
{section.title}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<PublicSectionGrid
|
||||
items={section.items}
|
||||
publicDashboardId={publicDashboardId}
|
||||
startMs={startMs}
|
||||
endMs={endMs}
|
||||
isVisible
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardV2;
|
||||
@@ -0,0 +1,10 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { noop } from 'lodash-es';
|
||||
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import { usePublicPanelQuery } from '../hooks/usePublicPanelQuery';
|
||||
import styles from './PublicPanel.module.scss';
|
||||
|
||||
interface PublicPanelProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
|
||||
panelKey: string;
|
||||
publicDashboardId: string;
|
||||
/** Epoch milliseconds. */
|
||||
startMs: number;
|
||||
/** Epoch milliseconds. */
|
||||
endMs: number;
|
||||
/** True once the panel is on screen — gates the fetch. */
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const PUBLIC_DASHBOARD_PREFERENCE: DashboardPreference = {
|
||||
syncMode: DashboardCursorSync.None,
|
||||
};
|
||||
|
||||
// Read-only v2 public panel: reuses the V2 header/body renderers with interactions disabled.
|
||||
function PublicPanel({
|
||||
panel,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
isVisible,
|
||||
}: PublicPanelProps): JSX.Element {
|
||||
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
|
||||
|
||||
const { data, isFetching, isPreviousData, error, refetch } =
|
||||
usePublicPanelQuery({
|
||||
panel,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
enabled: isVisible !== false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.panel} data-panel-root={panelKey}>
|
||||
<PanelHeader
|
||||
panelId={panelKey}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={panel}
|
||||
panelId={panelKey}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={noop}
|
||||
dashboardPreference={PUBLIC_DASHBOARD_PREFERENCE}
|
||||
enableDrillDown={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicPanel;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePublicPanelQuery } from '../../hooks/usePublicPanelQuery';
|
||||
import PublicPanel from '../PublicPanel';
|
||||
|
||||
jest.mock('../../hooks/usePublicPanelQuery', () => ({
|
||||
usePublicPanelQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
// Stub the reused V2 renderers so the test targets PublicPanel's own wiring, not uPlot/timezone.
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({ hideActions }: { hideActions?: boolean }): JSX.Element => (
|
||||
<div data-testid="panel-header" data-hide-actions={String(!!hideActions)} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
enableDrillDown,
|
||||
panelId,
|
||||
}: {
|
||||
enableDrillDown?: boolean;
|
||||
panelId: string;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
data-testid="panel-body"
|
||||
data-drilldown={String(!!enableDrillDown)}
|
||||
data-panel-id={panelId}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockQuery = usePublicPanelQuery as jest.Mock;
|
||||
|
||||
const queryResult = {
|
||||
data: { response: undefined, requestPayload: undefined, legendMap: {} },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isPreviousData: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
cancelQuery: jest.fn(),
|
||||
pagination: undefined,
|
||||
};
|
||||
|
||||
const timeseriesPanel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'panel-1' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const commonProps = {
|
||||
panelKey: 'p1',
|
||||
publicDashboardId: 'pub-1',
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
};
|
||||
|
||||
describe('PublicPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockReset();
|
||||
mockQuery.mockReturnValue(queryResult);
|
||||
});
|
||||
|
||||
it('renders the reused header/body read-only (hideActions, no drill-down)', () => {
|
||||
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
|
||||
|
||||
expect(screen.getByTestId('panel-header')).toHaveAttribute(
|
||||
'data-hide-actions',
|
||||
'true',
|
||||
);
|
||||
const body = screen.getByTestId('panel-body');
|
||||
expect(body).toHaveAttribute('data-drilldown', 'false');
|
||||
expect(body).toHaveAttribute('data-panel-id', 'p1');
|
||||
});
|
||||
|
||||
it('fetches by panel key and time', () => {
|
||||
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panelKey: 'p1',
|
||||
publicDashboardId: 'pub-1',
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('gates the fetch when off screen', () => {
|
||||
render(
|
||||
<PublicPanel panel={timeseriesPanel} {...commonProps} isVisible={false} />,
|
||||
);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { GridItem } from 'pages/DashboardPageV2/DashboardContainer/utils';
|
||||
import GridLayout, { type Layout, WidthProvider } from 'react-grid-layout';
|
||||
|
||||
import PublicPanel from '../PublicPanel/PublicPanel';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
|
||||
interface PublicSectionGridProps {
|
||||
items: GridItem[];
|
||||
publicDashboardId: string;
|
||||
/** Epoch milliseconds. */
|
||||
startMs: number;
|
||||
/** Epoch milliseconds. */
|
||||
endMs: number;
|
||||
/** True once the section is on screen — forwarded to gate panel fetches. */
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
// Fixed (non-editable) grid for one section of a v2 public dashboard.
|
||||
function PublicSectionGrid({
|
||||
items,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
isVisible,
|
||||
}: PublicSectionGridProps): JSX.Element {
|
||||
const layout: Layout[] = items.map((item) => ({
|
||||
i: item.id,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
static: true,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
cols={12}
|
||||
rowHeight={45}
|
||||
autoSize
|
||||
useCSSTransforms
|
||||
layout={layout}
|
||||
isDraggable={false}
|
||||
isResizable={false}
|
||||
margin={[8, 8]}
|
||||
>
|
||||
{items.map((item) => (
|
||||
// Empty cell for an orphan layout item (panel id missing from the map).
|
||||
<div key={item.id}>
|
||||
{item.panel && (
|
||||
<PublicPanel
|
||||
panel={item.panel}
|
||||
panelKey={item.id}
|
||||
publicDashboardId={publicDashboardId}
|
||||
startMs={startMs}
|
||||
endMs={endMs}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicSectionGrid;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import PublicDashboardV2 from '../PublicDashboardV2';
|
||||
|
||||
const mockGrid = jest.fn();
|
||||
|
||||
jest.mock('../PublicSectionGrid/PublicSectionGrid', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown): JSX.Element => {
|
||||
mockGrid(props);
|
||||
return <div data-testid="public-section-grid" />;
|
||||
},
|
||||
}));
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="datetime-selection" />,
|
||||
}));
|
||||
jest.mock('../PublicAutoRefresh/PublicAutoRefresh', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="auto-refresh" />,
|
||||
}));
|
||||
|
||||
function buildData(
|
||||
timeRangeEnabled: boolean,
|
||||
): DashboardtypesGettablePublicDashboardDataV2DTO {
|
||||
return {
|
||||
dashboard: {
|
||||
schemaVersion: 'v6',
|
||||
spec: {
|
||||
display: { name: 'My V2 Dashboard' },
|
||||
layouts: [
|
||||
{
|
||||
kind: 'Grid',
|
||||
spec: {
|
||||
display: { title: 'Section A' },
|
||||
items: [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 6,
|
||||
height: 6,
|
||||
content: { $ref: '#/spec/panels/p1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
panels: {
|
||||
p1: {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'p1' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
variables: [],
|
||||
},
|
||||
},
|
||||
publicDashboard: { timeRangeEnabled, defaultTimeRange: '30m' },
|
||||
} as unknown as DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
}
|
||||
|
||||
describe('PublicDashboardV2', () => {
|
||||
beforeEach(() => mockGrid.mockReset());
|
||||
|
||||
it('renders the dashboard title, section title, and a grid per section', () => {
|
||||
render(
|
||||
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(true)} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('My V2 Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Section A')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('public-section-grid')).toBeInTheDocument();
|
||||
|
||||
const gridProps = mockGrid.mock.calls[0][0];
|
||||
expect(gridProps.publicDashboardId).toBe('pub-1');
|
||||
expect(gridProps.items).toHaveLength(1);
|
||||
expect(gridProps.items[0].id).toBe('p1');
|
||||
expect(typeof gridProps.startMs).toBe('number');
|
||||
expect(typeof gridProps.endMs).toBe('number');
|
||||
// Times are handed to the endpoint in milliseconds.
|
||||
expect(gridProps.endMs).toBeGreaterThan(gridProps.startMs);
|
||||
});
|
||||
|
||||
it('shows the time controls only when the publisher enabled the time range', () => {
|
||||
const { rerender } = render(
|
||||
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(true)} />,
|
||||
);
|
||||
expect(screen.getByTestId('datetime-selection')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('auto-refresh')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<PublicDashboardV2 publicDashboardId="pub-1" data={buildData(false)} />,
|
||||
);
|
||||
expect(screen.queryByTestId('datetime-selection')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('auto-refresh')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import { usePublicPanelQuery } from '../usePublicPanelQuery';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
getPublicDashboardPanelQueryRangeV2: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = getPublicDashboardPanelQueryRangeV2 as jest.Mock;
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
|
||||
// A timeseries panel with a single runnable builder query (non-metrics signal → runnable).
|
||||
const panel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'panel-1' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: { visualization: {} } },
|
||||
queries: [
|
||||
{
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
name: 'A',
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: { name: 'A', signal: 'logs', legend: 'My legend' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const args = {
|
||||
panel,
|
||||
panelKey: 'panel-1',
|
||||
publicDashboardId: 'pub-1',
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
};
|
||||
|
||||
describe('usePublicPanelQuery', () => {
|
||||
beforeEach(() => mockFetch.mockReset());
|
||||
|
||||
it('fetches by panel key + time and exposes the response as PanelQueryData', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { type: 'time_series', data: { results: [] } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePublicPanelQuery(args), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
{ id: 'pub-1', key: 'panel-1' },
|
||||
{ startTime: '1000', endTime: '2000' },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result.current.data.response?.status).toBe('success');
|
||||
expect(result.current.data.legendMap).toStrictEqual({ A: 'My legend' });
|
||||
expect(result.current.data.requestPayload?.start).toBe(1000);
|
||||
expect(result.current.data.requestPayload?.end).toBe(2000);
|
||||
// The public endpoint has no paging support.
|
||||
expect(result.current.pagination).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not fetch without a public dashboard id', () => {
|
||||
renderHook(() => usePublicPanelQuery({ ...args, publicDashboardId: '' }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch when the panel has no runnable queries', () => {
|
||||
const emptyPanel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'empty' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
renderHook(() => usePublicPanelQuery({ ...args, panel: emptyPanel }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch when disabled', () => {
|
||||
renderHook(() => usePublicPanelQuery({ ...args, enabled: false }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { retryUnlessClientError } from 'pages/DashboardPageV2/DashboardContainer/hooks/useGetQueryRangeV5';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
buildQueryRangeRequest,
|
||||
extractLegendMap,
|
||||
hasRunnableQueries,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
|
||||
import type {
|
||||
PanelQueryData,
|
||||
PanelPagination,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from 'react-query';
|
||||
|
||||
export interface UsePublicPanelQueryArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
|
||||
panelKey: string;
|
||||
publicDashboardId: string;
|
||||
/** Epoch milliseconds. */
|
||||
startMs: number;
|
||||
/** Epoch milliseconds. */
|
||||
endMs: number;
|
||||
/** Gate the fetch (default true). */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// Same shape as `usePanelQuery` so the V2 renderers consume it unchanged.
|
||||
export interface UsePublicPanelQueryResult {
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isPreviousData: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
cancelQuery: () => void;
|
||||
/** Always undefined — the public endpoint has no paging (#5557). */
|
||||
pagination?: PanelPagination;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one v2 public panel by key + time. The public endpoint holds the query server-side
|
||||
* (no body, variables, or paging); we still build the request DTO locally so the renderers get
|
||||
* the `requestPayload`/`legendMap` they need — it is not sent.
|
||||
*/
|
||||
export function usePublicPanelQuery({
|
||||
panel,
|
||||
panelKey,
|
||||
publicDashboardId,
|
||||
startMs,
|
||||
endMs,
|
||||
enabled = true,
|
||||
}: UsePublicPanelQueryArgs): UsePublicPanelQueryResult {
|
||||
const fullKind = panel.spec.plugin.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
|
||||
const { queries } = panel.spec;
|
||||
|
||||
const pluginSpec = panel.spec.plugin.spec;
|
||||
const visualization =
|
||||
pluginSpec && 'visualization' in pluginSpec
|
||||
? pluginSpec.visualization
|
||||
: undefined;
|
||||
const fillGaps = Boolean(
|
||||
visualization && 'fillSpans' in visualization && visualization.fillSpans,
|
||||
);
|
||||
|
||||
// For the renderers only — not sent to the server.
|
||||
const requestPayload = useMemo(
|
||||
() =>
|
||||
buildQueryRangeRequest({
|
||||
queries,
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
fillGaps,
|
||||
variables: {},
|
||||
}),
|
||||
[queries, panelType, startMs, endMs, fillGaps],
|
||||
);
|
||||
|
||||
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);
|
||||
const runnable = useMemo(() => hasRunnableQueries(queries), [queries]);
|
||||
|
||||
// Redacted payloads are identical across panels — key on panel + time to avoid cache collisions.
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_WIDGET_DATA,
|
||||
publicDashboardId,
|
||||
panelKey,
|
||||
startMs,
|
||||
endMs,
|
||||
],
|
||||
[publicDashboardId, panelKey, startMs, endMs],
|
||||
);
|
||||
|
||||
const response = useQuery<GetPublicDashboardPanelQueryRangeV2200, Error>({
|
||||
queryKey,
|
||||
queryFn: ({ signal }) =>
|
||||
getPublicDashboardPanelQueryRangeV2(
|
||||
{ id: publicDashboardId, key: panelKey },
|
||||
{ startTime: String(startMs), endTime: String(endMs) },
|
||||
signal,
|
||||
),
|
||||
enabled: enabled && runnable && !!publicDashboardId && !!panelKey,
|
||||
retry: retryUnlessClientError,
|
||||
});
|
||||
|
||||
const data = useMemo<PanelQueryData>(
|
||||
() => ({ response: response.data, requestPayload, legendMap }),
|
||||
[response.data, requestPayload, legendMap],
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const cancelQuery = useCallback((): void => {
|
||||
void queryClient.cancelQueries(queryKey);
|
||||
}, [queryClient, queryKey]);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading: response.isLoading,
|
||||
isFetching: response.isFetching,
|
||||
isPreviousData: response.isPreviousData,
|
||||
error: response.error ?? null,
|
||||
refetch: response.refetch,
|
||||
cancelQuery,
|
||||
pagination: undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const CUSTOM_TIME_REGEX = /^(\d+)([mhdw])$/;
|
||||
|
||||
const UNIT_TO_DAYJS = {
|
||||
m: 'minutes',
|
||||
h: 'hours',
|
||||
d: 'days',
|
||||
w: 'weeks',
|
||||
} as const;
|
||||
|
||||
// Relative range (`30m`/`6h`/`7d`/`1w`) → `{ startTime, endTime }` in unix seconds; default 30m.
|
||||
export function getStartTimeAndEndTimeFromTimeRange(timeRange: string): {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
} {
|
||||
const match = timeRange.match(CUSTOM_TIME_REGEX);
|
||||
if (match) {
|
||||
const timeValue = parseInt(match[1] as string, 10);
|
||||
const unit = UNIT_TO_DAYJS[match[2] as keyof typeof UNIT_TO_DAYJS];
|
||||
return {
|
||||
startTime: dayjs().subtract(timeValue, unit).unix(),
|
||||
endTime: dayjs().unix(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: dayjs().subtract(30, 'minutes').unix(),
|
||||
endTime: dayjs().unix(),
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useGetPublicDashboardData } from 'hooks/dashboard/useGetPublicDashboardData';
|
||||
import {
|
||||
PublicDashboardSchema,
|
||||
useGetResolvedPublicDashboard,
|
||||
} from 'hooks/dashboard/useGetResolvedPublicDashboard';
|
||||
import { Frown } from '@signozhq/icons';
|
||||
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import PublicDashboardContainer from '../../container/PublicDashboardContainer';
|
||||
import PublicDashboardV2 from './PublicDashboardV2/PublicDashboardV2';
|
||||
|
||||
import './PublicDashboard.styles.scss';
|
||||
|
||||
@@ -14,27 +18,28 @@ function PublicDashboardPage(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
const {
|
||||
data: publicDashboardData,
|
||||
isLoading: isLoadingPublicDashboardData,
|
||||
isFetching: isFetchingPublicDashboardData,
|
||||
isError: isErrorPublicDashboardData,
|
||||
} = useGetPublicDashboardData(dashboardId || '');
|
||||
data: resolved,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
} = useGetResolvedPublicDashboard(dashboardId || '');
|
||||
|
||||
const isLoading =
|
||||
isLoadingPublicDashboardData || isFetchingPublicDashboardData;
|
||||
|
||||
const isError = isErrorPublicDashboardData;
|
||||
const isBusy = isLoading || isFetching;
|
||||
|
||||
return (
|
||||
<div className="public-dashboard-page">
|
||||
{publicDashboardData && (
|
||||
{resolved?.schema === PublicDashboardSchema.V2 && (
|
||||
<PublicDashboardV2 publicDashboardId={dashboardId} data={resolved.data} />
|
||||
)}
|
||||
|
||||
{resolved?.schema === PublicDashboardSchema.V1 && (
|
||||
<PublicDashboardContainer
|
||||
publicDashboardId={dashboardId}
|
||||
publicDashboardData={publicDashboardData}
|
||||
publicDashboardData={{ httpStatusCode: 200, data: resolved.data }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isError && !isLoading && (
|
||||
{isError && !isBusy && (
|
||||
<div className="public-dashboard-error-container">
|
||||
<div className="perilin-bg" />
|
||||
|
||||
|
||||
@@ -34,6 +34,22 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Logs-tab footer. Sibling of the scrolling .panelBody inside .panel, so it's
|
||||
// a true fixed footer flush with the panel's bottom edge — always visible
|
||||
// regardless of how the body is scrolled.
|
||||
.logsFooter {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
// Inset to match panelBody's 12px padding so the border-top aligns with
|
||||
// the content edges instead of bleeding to the panel border.
|
||||
margin: 0 12px;
|
||||
padding: 8px 0 12px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
// Single scroll: when the summary sits above (non-docked modes) it scrolls away
|
||||
// and the tab section pins to the top; tab content scrolls inside `.tabsScroll`.
|
||||
.tabsSection {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import {
|
||||
TabsContent,
|
||||
@@ -49,6 +49,7 @@ import DockModeSwitcher from './DockModeSwitcher';
|
||||
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
|
||||
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
|
||||
import Events from './Events/Events';
|
||||
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
import SpanSummary from './SpanSummary';
|
||||
@@ -68,6 +69,9 @@ interface SpanDetailsPanelProps {
|
||||
// dock, or a floating/right panel widened to match). ~right-dock max width.
|
||||
const WIDE_PANEL_BREAKPOINT = 720;
|
||||
|
||||
// Context-log window padding around the span's trace time range.
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
|
||||
function SpanDetailsContent({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
@@ -77,12 +81,15 @@ function SpanDetailsContent({
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
}): JSX.Element {
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
const [bodyRef, { width: bodyWidth }] = useMeasure<HTMLDivElement>();
|
||||
const spanAttributeActions = useSpanAttributeActions();
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', selectedSpan.trace_id);
|
||||
// Tracked so the panel can render tab-specific chrome (the Logs footer)
|
||||
// outside the scrolling tab content.
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const handleTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
setActiveTab(tab);
|
||||
logTraceEvent(TraceDetailEvents.SpanPanelTabChanged, {
|
||||
[TraceDetailEventKeys.Tab]: tab,
|
||||
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
|
||||
@@ -281,89 +288,99 @@ function SpanDetailsContent({
|
||||
const eventsCount = selectedSpan.events?.length || 0;
|
||||
|
||||
return (
|
||||
<div className={styles.panelBody} ref={bodyRef}>
|
||||
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
|
||||
<>
|
||||
<div className={styles.panelBody} ref={bodyRef}>
|
||||
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
{/* Step 9: ContentTabs */}
|
||||
<TabsRoot defaultValue="overview" onValueChange={handleTabChange}>
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="overview" variant="secondary">
|
||||
<Bookmark size={14} /> Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" variant="secondary">
|
||||
<ScrollText size={14} /> Events
|
||||
{eventsCount > 0 && (
|
||||
<Badge color="secondary" className={styles.eventsBadge}>
|
||||
{eventsCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" variant="secondary">
|
||||
<List size={14} /> Logs
|
||||
</TabsTrigger>
|
||||
{infraMetadata && (
|
||||
<TabsTrigger value="metrics" variant="secondary">
|
||||
<ChartColumnBig size={14} /> Metrics
|
||||
<div className={styles.tabsSection}>
|
||||
{/* Step 9: ContentTabs */}
|
||||
<TabsRoot defaultValue="overview" onValueChange={handleTabChange}>
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="overview" variant="secondary">
|
||||
<Bookmark size={14} /> Overview
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
<TabsTrigger value="events" variant="secondary">
|
||||
<ScrollText size={14} /> Events
|
||||
{eventsCount > 0 && (
|
||||
<Badge color="secondary" className={styles.eventsBadge}>
|
||||
{eventsCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" variant="secondary">
|
||||
<List size={14} /> Logs
|
||||
</TabsTrigger>
|
||||
{infraMetadata && (
|
||||
<TabsTrigger value="metrics" variant="secondary">
|
||||
<ChartColumnBig size={14} /> Metrics
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className={styles.tabsScroll}>
|
||||
<TabsContent value="overview">
|
||||
{isWide && summary}
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
prettyViewProps={{
|
||||
showPinned: true,
|
||||
actions: prettyViewCustomActions,
|
||||
visibleActions: VISIBLE_ACTIONS,
|
||||
pinnedFieldsValue,
|
||||
onPinnedFieldsChange,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
<Events
|
||||
span={selectedSpan}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs">
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.trace_id}
|
||||
spanId={selectedSpan.span_id}
|
||||
timeRange={{
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLogsLoading}
|
||||
isError={isLogsError}
|
||||
isFetching={isLogsFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyLogsStateConfig : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
{infraMetadata && (
|
||||
<TabsContent value="metrics">
|
||||
<InfraMetrics
|
||||
clusterName={infraMetadata.clusterName}
|
||||
podName={infraMetadata.podName}
|
||||
nodeName={infraMetadata.nodeName}
|
||||
hostName={infraMetadata.hostName}
|
||||
timestamp={infraMetadata.spanTimestamp}
|
||||
dataSource={DataSource.TRACES}
|
||||
<div className={styles.tabsScroll}>
|
||||
<TabsContent value="overview">
|
||||
{isWide && summary}
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
prettyViewProps={{
|
||||
showPinned: true,
|
||||
actions: prettyViewCustomActions,
|
||||
visibleActions: VISIBLE_ACTIONS,
|
||||
pinnedFieldsValue,
|
||||
onPinnedFieldsChange,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
<TabsContent value="events">
|
||||
<Events
|
||||
span={selectedSpan}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs">
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.trace_id}
|
||||
spanId={selectedSpan.span_id}
|
||||
timeRange={{
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLogsLoading}
|
||||
isError={isLogsError}
|
||||
isFetching={isLogsFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyLogsStateConfig : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
{infraMetadata && (
|
||||
<TabsContent value="metrics">
|
||||
<InfraMetrics
|
||||
clusterName={infraMetadata.clusterName}
|
||||
podName={infraMetadata.podName}
|
||||
nodeName={infraMetadata.nodeName}
|
||||
hostName={infraMetadata.hostName}
|
||||
timestamp={infraMetadata.spanTimestamp}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sibling of the scrolling panelBody, so it's a true fixed footer flush
|
||||
with the panel's bottom edge — always visible, never scrolls. */}
|
||||
{activeTab === 'logs' && (
|
||||
<div className={styles.logsFooter}>
|
||||
<OpenInLogsExplorer onClick={handleExplorerPageRedirect} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
|
||||
interface OpenInLogsExplorerProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
// Opens the full Logs Explorer (new tab) filtered to this span's trace.
|
||||
// Placement/alignment is the caller's responsibility.
|
||||
function OpenInLogsExplorer({ onClick }: OpenInLogsExplorerProps): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClick}
|
||||
prefix={<Compass size={16} />}
|
||||
data-testid="open-in-explorer-button"
|
||||
>
|
||||
Open in Logs Explorer
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpenInLogsExplorer;
|
||||
@@ -1,5 +1,4 @@
|
||||
.spanLogs {
|
||||
margin-inline: var(--spacing-8);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import SpanLogs from '../SpanLogs';
|
||||
|
||||
@@ -82,8 +84,10 @@ jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock OverlayScrollbar
|
||||
// Mock OverlayScrollbar (default export — needs __esModule for the interop
|
||||
// unwrap, otherwise the default import resolves to the module object).
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: any): JSX.Element => (
|
||||
<div data-testid="overlay-scrollbar">{children}</div>
|
||||
),
|
||||
@@ -110,6 +114,13 @@ jest.mock(
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_SPAN_ID = 'test-span-id';
|
||||
|
||||
const sampleLog = {
|
||||
id: 'log-1',
|
||||
body: 'sample log body',
|
||||
timestamp: '1640995200000',
|
||||
spanID: TEST_SPAN_ID,
|
||||
} as unknown as ILog;
|
||||
|
||||
const defaultProps = {
|
||||
traceId: TEST_TRACE_ID,
|
||||
spanId: TEST_SPAN_ID,
|
||||
@@ -207,4 +218,22 @@ describe('SpanLogs', () => {
|
||||
|
||||
expect(mockHandleExplorerPageRedirect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens a new tab to Logs Explorer with the trace_id + span_id query when a log row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<SpanLogs {...defaultProps} logs={[sampleLog]} />);
|
||||
|
||||
await user.click(screen.getByTestId(`raw-log-${sampleLog.id}`));
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledTimes(1);
|
||||
const [url, target] = mockWindowOpen.mock.calls[0];
|
||||
// Opens Logs Explorer in a new tab, filtered to this trace/span, and
|
||||
// deep-links to the clicked log.
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain(TEST_TRACE_ID);
|
||||
expect(url).toContain(TEST_SPAN_ID);
|
||||
expect(url).toContain(sampleLog.id); // activeLogId
|
||||
expect(target).toBe('_blank');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { SpanDetailVariant } from '../constants';
|
||||
import SpanDetailsPanel from '../SpanDetailsPanel';
|
||||
|
||||
// Mock window.open for the Open in Logs Explorer footer redirect.
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', {
|
||||
value: mockWindowOpen,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Placement is width-driven via useMeasure (jsdom reports 0), so we control the
|
||||
// reported width per test. `mock` prefix lets the jest.mock factory reference it.
|
||||
let mockWidth = 0;
|
||||
@@ -157,3 +165,42 @@ describe('SpanDetailsPanel – tabs', () => {
|
||||
expect(screen.getByTestId('logs-tab')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// The footer lives on the panel body (not inside the tab content) so it stays
|
||||
// pinned to the panel's visible bottom edge; it should track the active tab.
|
||||
describe('SpanDetailsPanel – Open in Logs Explorer footer', () => {
|
||||
beforeEach(() => {
|
||||
mockWindowOpen.mockClear();
|
||||
});
|
||||
|
||||
it('appears only while the Logs tab is active', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderPanel(400);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('open-in-explorer-button'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /logs/i }));
|
||||
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /overview/i }));
|
||||
expect(
|
||||
screen.queryByTestId('open-in-explorer-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens Logs Explorer in a new tab filtered to the span trace when clicked', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderPanel(400);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /logs/i }));
|
||||
await user.click(screen.getByTestId('open-in-explorer-button'));
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledTimes(1);
|
||||
const [url, target] = mockWindowOpen.mock.calls[0];
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain('trace-1');
|
||||
expect(target).toBe('_blank');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,8 +76,7 @@
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.filter {
|
||||
// Width is owned by ResizableBox (inline style).
|
||||
flex-shrink: 0;
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
@@ -85,14 +84,9 @@
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +101,6 @@
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
@@ -40,8 +41,6 @@ import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { TOOLBAR_VIEWS } from 'pages/TracesExplorer/constants';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Warning } from 'types/api';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -63,10 +62,6 @@ import TimeSeriesView from './TimeSeriesView';
|
||||
|
||||
import './TracesExplorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function TracesExplorer(): JSX.Element {
|
||||
const {
|
||||
panelType,
|
||||
@@ -122,16 +117,6 @@ function TracesExplorer(): JSX.Element {
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
const [isOpen, setOpen] = useState<boolean>(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_TRACES,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
@@ -265,29 +250,16 @@ function TracesExplorer(): JSX.Element {
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="trace-explorer-page">
|
||||
{isOpen && (
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="filter"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</ResizableBox>
|
||||
)}
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.resizable-box {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&--disabled {
|
||||
flex: 1;
|
||||
@@ -17,17 +18,6 @@
|
||||
z-index: 10;
|
||||
background: var(--l2-border);
|
||||
|
||||
// Extend the interactive area beyond the 1px visual line so the handle
|
||||
// is easy to grab and double-click, without changing its appearance.
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background: var(--primary);
|
||||
@@ -65,29 +55,4 @@
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Visible grip indicator (opt-in via the `withHandle` prop). Purely visual —
|
||||
// pointer events fall through to the handle so it still owns drag + reset.
|
||||
&__grip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l1-background);
|
||||
color: var(--l2-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__handle:hover &__grip,
|
||||
&__handle:active &__grip {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user