Compare commits

..

9 Commits

Author SHA1 Message Date
Vinicius Lourenço
7fd6852fa6 refactor(authz): use new authz components for roles (#12011) 2026-07-10 12:19:09 +00:00
Vinicius Lourenço
b5567d1550 refactor(authz): use new authz components for service accounts (#12007) 2026-07-10 12:18:55 +00:00
Yunus M
2017660eb8 feat(quick-filters): add end-to-end tests and helper functions for qu… (#12063)
* feat(quick-filters): add end-to-end tests and helper functions for quick filters settings panel

* chore: fix formatting issues

* fix: add classname to skeleton and assert on the classname

* fix: address review comments

* chore: fix formatting issues
2026-07-10 11:54:14 +00:00
Aditya Singh
ca5d888970 feat(trace-details): restore Open in Logs Explorer in span details Logs tab (#11941)
* feat(trace-details): add Open in Logs Explorer to span details Logs tab

Restores the Open in Logs Explorer action (removed with V2 in #11805): a
toolbar button above the log list and an inline action in the no-trace-logs
empty state. Also tightens the empty-state RESOURCES card layout (stacked,
content-width, no extra height).

* feat: add logs explorer btn in footer

* test(trace-details): cover Open in Logs Explorer footer button in span details

* test(trace-details): assert log-row click opens Logs Explorer with trace/span query

* style(trace-details): fix oxfmt formatting in SpanLogs test

* fix(trace-details): pin Open in Logs Explorer footer to the panel bottom

The footer lived inside the scrolling tab content, so it was pushed below
the fold whenever the span summary above the tabs was in view. Render it as
a sibling of the scrolling panelBody instead (Logs tab only), making it a
true fixed footer that is always visible.
2026-07-10 07:36:05 +00:00
Yunus M
76b4d249e2 chore: update package dependencies in package.json and pnpm-lock.yaml (#12066)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-10 05:56:13 +00:00
Pandey
ef892f3361 fix(ruletypes): tag rule threshold targets as format: double (#12061)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(ruletypes): tag rule threshold targets as format: double

BasicRuleThreshold.{TargetValue,RecoveryTarget} and RuleCondition.Target are
*float64 but emitted a bare 'type: number' (swaggest sets format: double only for
non-pointer floats). Bare number makes oapi-codegen clients generate float32, so a
value like 0.8 loses precision on round-trip. Tag them format:double to match
non-pointer float64 fields (e.g. MetrictypesComparisonSpaceAggregationParam).

* chore(frontend): regenerate API client for rule threshold format: double

Reflects the format: double schema change on the rule threshold/condition
targets in the orval-generated client (oxfmt + oxlint applied).
2026-07-09 22:09:25 +00:00
Yunus M
9c4046d84a fix: revert the resizeable quick filter changes (#12058) 2026-07-09 19:00:19 +00:00
Vikrant Gupta
a91cee2b95 chore(sql): enable txlock immediate by default (#12048)
* chore(sql): enable txlock immediate by default

* chore(sql): remove the redundant flag from integration tests
2026-07-09 16:05:13 +00:00
Ashwin Bhatkal
51036d6cc4 feat(public-dashboard): integrate v2 (Perses-spec) public dashboards (#12032)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(public-dashboard): detect v1 vs v2 schema for the public viewer

Anonymous public viewers have no feature flags, so the schema can't be read from
use_dashboard_v2. Probe the v2 model endpoint first and fall back to v1 only on the
'dashboard_invalid_data' (HTTP 501) schema-mismatch signal. Probing v2 first also stops
the v1 endpoint from serving v2 dashboards with un-redacted queries.

* feat(public-dashboard): fetch v2 public panel data by key

Adds a by-key fetcher over the anonymous /api/v2/public/dashboards/{id}/panels/{key}/query_range
endpoint (the generated client omits the startTime/endTime params) and a store-free
usePublicPanelQuery that mirrors usePanelQuery's PanelQueryData shape. No variables and no
pagination — the public endpoint supports neither.

* feat(public-dashboard): render v2 public dashboards read-only

Adds a read-only v2 viewer that reuses the authenticated V2 panel renderers
(PanelHeader with hideActions, PanelBody, panel registry) and the pure layoutsToSections
util, with a forked read-only grid. The public page branches on the resolved schema:
v1 keeps the existing container, v2 renders the new viewer. Dashboard variables are not
rendered — the public endpoint does not substitute them.

* feat(public-dashboard): match the standard auto-refresh control

Replace the hand-rolled 'Off' select (which was styled inconsistently and clipped its
options) with a PublicAutoRefresh that mirrors the app's DateTimeSelectionV2 refresh cluster:
a grouped refresh button + auto-refresh popover (Auto Refresh checkbox + full interval list),
portal-rendered so nothing clips. It's prop-driven — the public viewer keeps managing its own
time window — so the container now tracks enabled + interval and exposes a manual refresh.
Also nudge the header-right gap 8→12px.

* feat(public-dashboard): declare v2 query_range params, drop the wrapper, address review

Declare startTime/endTime as query params on the v2 public query_range endpoint via
RequestQuery and regenerate the OpenAPI spec + orval client, so the generated
getPublicDashboardPanelQueryRangeV2 carries them. usePublicPanelQuery now calls the
generated fetcher directly and the hand-written wrapper is removed.

Also from review: drop the defensive panelDefinition guard so an unsupported kind
surfaces loudly, use lodash noop, and trim excessive comments across the v2 files.

* fix: bind query params from PublicWidgetQueryRangeParams

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-07-09 13:27:17 +00:00
116 changed files with 2946 additions and 2272 deletions

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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/

View File

@@ -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:

View File

@@ -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

View File

@@ -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
View File

@@ -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: {}

View File

@@ -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,
);

View File

@@ -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;
/**

View File

@@ -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>
);

View File

@@ -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}
/>

View File

@@ -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>
</>

View File

@@ -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>
);

View File

@@ -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>
</>

View File

@@ -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]} &#8212; {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 }} />,
});

View File

@@ -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 }} />,
});

View File

@@ -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 (

View File

@@ -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]} &#8212; {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>
);
}

View File

@@ -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(),
);
});

View File

@@ -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(),
);
});

View File

@@ -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'));

View File

@@ -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();
});
});

View File

@@ -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';

View File

@@ -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',

View File

@@ -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',

View File

@@ -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);
}
}
}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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}${

View File

@@ -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 {

View File

@@ -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}${

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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">

View File

@@ -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>
),
});

View File

@@ -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();

View File

@@ -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();
});
});
});

View File

@@ -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');

View File

@@ -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();
});
});
});

View File

@@ -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'),

View File

@@ -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);

View File

@@ -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');

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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>
),
});

View File

@@ -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} />

View File

@@ -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>
),
});

View File

@@ -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/),

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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);
});

View File

@@ -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(

View File

@@ -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();
});
});
});
});

View File

@@ -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', () => {

View File

@@ -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();
});
});

View File

@@ -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);

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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,
};
}

View File

@@ -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();
});

View File

@@ -44,6 +44,7 @@
display: flex;
align-items: center;
gap: var(--spacing-4);
padding-bottom: var(--spacing-6);
}
&__search {

View File

@@ -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 />

View File

@@ -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 }),

View File

@@ -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();
});
});

View File

@@ -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,
});

View File

@@ -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);
}
}
}

View File

@@ -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(

View File

@@ -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;

View File

@@ -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);
}
}
}

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 }),
);
});
});

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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,
};
}

View File

@@ -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(),
};
}

View File

@@ -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" />

View File

@@ -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 {

View File

@@ -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>
)}
</>
);
}

View File

@@ -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;

View File

@@ -1,5 +1,4 @@
.spanLogs {
margin-inline: var(--spacing-8);
height: 100%;
display: flex;
flex-direction: column;

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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