mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-08 15:40:40 +01:00
Compare commits
22 Commits
feat/expor
...
ci/better-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bfd604aa4 | ||
|
|
14ba332935 | ||
|
|
fda7b66d49 | ||
|
|
de2212e8b9 | ||
|
|
ff4f18985c | ||
|
|
7d555b643a | ||
|
|
7839e806fd | ||
|
|
2dcd51391d | ||
|
|
6251616d9d | ||
|
|
652a9044f4 | ||
|
|
d4646657ca | ||
|
|
29681572db | ||
|
|
a3fa475e88 | ||
|
|
64d0ada495 | ||
|
|
60886fd906 | ||
|
|
d8954da307 | ||
|
|
7476a2cacb | ||
|
|
e1dd7d52eb | ||
|
|
1644e35d9c | ||
|
|
379331ee77 | ||
|
|
4d079a8338 | ||
|
|
91938925ec |
41
.github/workflows/jsci.yaml
vendored
41
.github/workflows/jsci.yaml
vendored
@@ -102,3 +102,44 @@ jobs:
|
||||
run: |
|
||||
cd frontend && pnpm generate:config:web-settings
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated web settings types. Run pnpm generate:config:web-settings in frontend/ locally and commit."; exit 1)
|
||||
code-quality:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: node-install
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
- name: install-frontend
|
||||
run: cd frontend && pnpm install
|
||||
- name: get-new-files
|
||||
id: new-files
|
||||
run: |
|
||||
cd frontend
|
||||
NEW_TS=$(git diff --name-only --diff-filter=A origin/main...HEAD -- src/ | grep -E '\.(ts|tsx)$' | tr '\n' ' ' || true)
|
||||
NEW_SCSS=$(git diff --name-only --diff-filter=A origin/main...HEAD -- src/ | grep -E '\.scss$' | tr '\n' ' ' || true)
|
||||
echo "ts=$NEW_TS" >> $GITHUB_OUTPUT
|
||||
echo "scss=$NEW_SCSS" >> $GITHUB_OUTPUT
|
||||
echo "New TS files: $NEW_TS"
|
||||
echo "New SCSS files: $NEW_SCSS"
|
||||
- name: lint-new-ts-files
|
||||
if: steps.new-files.outputs.ts != ''
|
||||
run: |
|
||||
cd frontend
|
||||
pnpm oxlint -c .oxlintrc.strict.json ${{ steps.new-files.outputs.ts }}
|
||||
- name: lint-new-scss-files
|
||||
if: steps.new-files.outputs.scss != ''
|
||||
run: |
|
||||
cd frontend
|
||||
pnpm stylelint --config .stylelintrc.strict.cjs ${{ steps.new-files.outputs.scss }}
|
||||
|
||||
@@ -155,8 +155,8 @@ querier:
|
||||
cache_ttl: 168h
|
||||
# The interval for recent data that should not be cached.
|
||||
flux_interval: 5m
|
||||
# The maximum number of concurrent queries for missing ranges.
|
||||
max_concurrent_queries: 4
|
||||
# The maximum number of queries a single query range request runs at once.
|
||||
max_concurrent_queries: 8
|
||||
# When filtering logs by trace_id, clamp the query window to the trace time
|
||||
# range with padding to include slightly delayed log exports. Logs only; set
|
||||
# to 0 to disable.
|
||||
|
||||
@@ -515,6 +515,13 @@ components:
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesDeprecatedPostableUserRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesGettableAuthDomain:
|
||||
properties:
|
||||
authNProviderInfo:
|
||||
@@ -660,17 +667,20 @@ components:
|
||||
type: string
|
||||
userRoles:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUserRole'
|
||||
$ref: '#/components/schemas/AuthtypesDeprecatedPostableUserRole'
|
||||
type: array
|
||||
required:
|
||||
- email
|
||||
type: object
|
||||
AuthtypesPostableUserRole:
|
||||
properties:
|
||||
id:
|
||||
roleId:
|
||||
type: string
|
||||
userId:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- userId
|
||||
- roleId
|
||||
type: object
|
||||
AuthtypesRelation:
|
||||
enum:
|
||||
@@ -4251,6 +4261,8 @@ components:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- clusterName
|
||||
- clusterCPU
|
||||
@@ -4259,6 +4271,7 @@ components:
|
||||
- clusterMemoryAllocatable
|
||||
- nodeCountsByReadiness
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesClusters:
|
||||
@@ -4314,6 +4327,8 @@ components:
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- daemonSetName
|
||||
- daemonSetCPU
|
||||
@@ -4325,6 +4340,7 @@ components:
|
||||
- desiredNodes
|
||||
- currentNodes
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesDaemonSets:
|
||||
@@ -4380,6 +4396,8 @@ components:
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- deploymentName
|
||||
- deploymentCPU
|
||||
@@ -4391,6 +4409,7 @@ components:
|
||||
- desiredPods
|
||||
- availablePods
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesDeployments:
|
||||
@@ -4523,6 +4542,8 @@ components:
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
successfulPods:
|
||||
type: integer
|
||||
required:
|
||||
@@ -4538,6 +4559,7 @@ components:
|
||||
- failedPods
|
||||
- successfulPods
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesJobs:
|
||||
@@ -4628,11 +4650,14 @@ components:
|
||||
type: string
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- namespaceName
|
||||
- namespaceCPU
|
||||
- namespaceMemory
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesNamespaces:
|
||||
@@ -4698,11 +4723,14 @@ components:
|
||||
type: string
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- nodeName
|
||||
- condition
|
||||
- nodeCountsByReadiness
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- nodeCPU
|
||||
- nodeCPUAllocatable
|
||||
- nodeMemory
|
||||
@@ -4748,6 +4776,64 @@ components:
|
||||
- failed
|
||||
- unknown
|
||||
type: object
|
||||
InframonitoringtypesPodCountsByStatus:
|
||||
properties:
|
||||
completed:
|
||||
type: integer
|
||||
containerCannotRun:
|
||||
type: integer
|
||||
containerCreating:
|
||||
type: integer
|
||||
crashLoopBackOff:
|
||||
type: integer
|
||||
createContainerConfigError:
|
||||
type: integer
|
||||
errImagePull:
|
||||
type: integer
|
||||
error:
|
||||
type: integer
|
||||
evicted:
|
||||
type: integer
|
||||
failed:
|
||||
type: integer
|
||||
imagePullBackOff:
|
||||
type: integer
|
||||
nodeAffinity:
|
||||
type: integer
|
||||
nodeLost:
|
||||
type: integer
|
||||
oomKilled:
|
||||
type: integer
|
||||
pending:
|
||||
type: integer
|
||||
running:
|
||||
type: integer
|
||||
shutdown:
|
||||
type: integer
|
||||
unexpectedAdmissionError:
|
||||
type: integer
|
||||
unknown:
|
||||
type: integer
|
||||
required:
|
||||
- pending
|
||||
- running
|
||||
- failed
|
||||
- unknown
|
||||
- crashLoopBackOff
|
||||
- imagePullBackOff
|
||||
- errImagePull
|
||||
- createContainerConfigError
|
||||
- containerCreating
|
||||
- oomKilled
|
||||
- completed
|
||||
- error
|
||||
- containerCannotRun
|
||||
- evicted
|
||||
- nodeAffinity
|
||||
- nodeLost
|
||||
- shutdown
|
||||
- unexpectedAdmissionError
|
||||
type: object
|
||||
InframonitoringtypesPodPhase:
|
||||
enum:
|
||||
- pending
|
||||
@@ -4778,6 +4864,8 @@ components:
|
||||
type: number
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
podMemory:
|
||||
format: double
|
||||
type: number
|
||||
@@ -4789,6 +4877,11 @@ components:
|
||||
type: number
|
||||
podPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
|
||||
podRestarts:
|
||||
format: int64
|
||||
type: integer
|
||||
podStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
|
||||
podUID:
|
||||
type: string
|
||||
required:
|
||||
@@ -4801,9 +4894,34 @@ components:
|
||||
- podMemoryLimit
|
||||
- podPhase
|
||||
- podCountsByPhase
|
||||
- podStatus
|
||||
- podCountsByStatus
|
||||
- podRestarts
|
||||
- podAge
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesPodStatus:
|
||||
enum:
|
||||
- pending
|
||||
- running
|
||||
- failed
|
||||
- unknown
|
||||
- crashloopbackoff
|
||||
- imagepullbackoff
|
||||
- errimagepull
|
||||
- createcontainerconfigerror
|
||||
- containercreating
|
||||
- oomkilled
|
||||
- completed
|
||||
- error
|
||||
- containercannotrun
|
||||
- evicted
|
||||
- nodeaffinity
|
||||
- nodelost
|
||||
- shutdown
|
||||
- unexpectedadmissionerror
|
||||
- no_data
|
||||
type: string
|
||||
InframonitoringtypesPods:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
@@ -5102,6 +5220,8 @@ components:
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
statefulSetCPU:
|
||||
format: double
|
||||
type: number
|
||||
@@ -5133,6 +5253,7 @@ components:
|
||||
- desiredPods
|
||||
- currentPods
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesStatefulSets:
|
||||
@@ -7430,6 +7551,13 @@ components:
|
||||
enum:
|
||||
- basic
|
||||
type: string
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
ServiceaccounttypesGettableFactorAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -7486,10 +7614,13 @@ components:
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
roleId:
|
||||
type: string
|
||||
serviceAccountId:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- serviceAccountId
|
||||
- roleId
|
||||
type: object
|
||||
ServiceaccounttypesServiceAccount:
|
||||
properties:
|
||||
@@ -12252,6 +12383,188 @@ paths:
|
||||
summary: Update route policy
|
||||
tags:
|
||||
- routepolicies
|
||||
/api/v1/service_account_roles:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint assigns a role to a service account
|
||||
operationId: CreateServiceAccountRole
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
- tokenizer:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
summary: Create service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_account_roles/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint revokes a role from a service account
|
||||
operationId: DeleteServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:detach
|
||||
- role:detach
|
||||
- tokenizer:
|
||||
- serviceaccount:detach
|
||||
- role:detach
|
||||
summary: Delete service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets an existing service account role
|
||||
operationId: GetServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:read
|
||||
- tokenizer:
|
||||
- serviceaccount:read
|
||||
summary: Get service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -12821,9 +13134,9 @@ paths:
|
||||
tags:
|
||||
- serviceaccount
|
||||
post:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint assigns a role to a service account
|
||||
operationId: CreateServiceAccountRole
|
||||
operationId: CreateServiceAccountRoleDeprecated
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -12834,7 +13147,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
|
||||
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
@@ -12886,9 +13199,9 @@ paths:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}/roles/{rid}:
|
||||
delete:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint revokes a role from service account
|
||||
operationId: DeleteServiceAccountRole
|
||||
operationId: DeleteServiceAccountRoleDeprecated
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -21990,6 +22303,184 @@ paths:
|
||||
summary: Rotate session
|
||||
tags:
|
||||
- sessions
|
||||
/api/v2/user_roles:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint assigns a role to a user
|
||||
operationId: CreateUserRole
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUserRole'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create user role
|
||||
tags:
|
||||
- users
|
||||
/api/v2/user_roles/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint revokes a role from a user
|
||||
operationId: DeleteUserRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Delete user role
|
||||
tags:
|
||||
- users
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets an existing user role
|
||||
operationId: GetUserRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesUserRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Get user role
|
||||
tags:
|
||||
- users
|
||||
/api/v2/users:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -22443,7 +22934,7 @@ paths:
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint assigns the role to the user roles by user id
|
||||
operationId: SetRoleByUserID
|
||||
parameters:
|
||||
@@ -22494,7 +22985,7 @@ paths:
|
||||
- users
|
||||
/api/v2/users/{id}/roles/{roleId}:
|
||||
delete:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint removes a role from the user by user id and role
|
||||
id
|
||||
operationId: RemoveUserRoleByUserIDAndRoleID
|
||||
|
||||
149
docs/contributing/go/authz.md
Normal file
149
docs/contributing/go/authz.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Authz
|
||||
|
||||
SigNoz uses [OpenFGA](https://openfga.dev/), a relationship-based access control (ReBAC) system, to authorize every request. Transactions are never attached to users directly — they are attached to **roles** (as relationship tuples in OpenFGA), and users or service accounts (principals) are made **assignees** of those roles. The central interface is `AuthZ` in [pkg/authz/authz.go](/pkg/authz/authz.go), backed by an embedded OpenFGA server in [pkg/authz/openfgaserver](/pkg/authz/openfgaserver/server.go).
|
||||
|
||||
As a feature author, you will rarely touch OpenFGA directly. You interact with two layers:
|
||||
|
||||
1. **The registries** in [pkg/types/coretypes](/pkg/types/coretypes) — where every resource, verb, and managed-role permission is declared in code.
|
||||
2. **The route wiring** in [pkg/apiserver/signozapiserver](/pkg/apiserver/signozapiserver) — where each route declares what resource it touches and which verb it needs, and the middleware turns that into a check.
|
||||
|
||||
## What are the building blocks?
|
||||
|
||||
### Type
|
||||
|
||||
A `Type` is the coarse FGA type of a resource. The set is finite and defined in [pkg/types/coretypes/registry_type.go](/pkg/types/coretypes/registry_type.go): `user`, `serviceaccount`, `anonymous`, `role`, `organization`, `metaresource`, and `telemetryresource`. Each type carries a selector regex (what a valid ID looks like) and the verbs allowed on it.
|
||||
|
||||
Almost every feature resource is a `metaresource` (dashboards, rules, pipelines, ...) or a `telemetryresource` (logs, traces, metrics). You should almost never need a new type.
|
||||
|
||||
### Verb
|
||||
|
||||
A `Verb` is the action being authorized. All verbs are defined in [pkg/types/coretypes/registry_verb.go](/pkg/types/coretypes/registry_verb.go): `create`, `read`, `update`, `delete`, `list`, `assignee`, `attach`, and `detach`.
|
||||
|
||||
- `assignee` is special: it is the membership relation between a subject and a role ("user X is an assignee of role Y"), not an action a route checks for.
|
||||
- `attach`/`detach` authorize linking two resources together (e.g. assigning a role to a service account).
|
||||
|
||||
### Kind
|
||||
|
||||
A `Kind` is the fine-grained name of your resource — `dashboard`, `rule`, `quick-filter`. Kinds are registered in [pkg/types/coretypes/registry_kind.go](/pkg/types/coretypes/registry_kind.go). A kind rides on top of an existing type, so adding one does **not** require any OpenFGA schema change.
|
||||
|
||||
### Resource
|
||||
|
||||
A `Resource` ties a type and a kind together and knows how to render itself as an FGA object string. The interface lives in [pkg/types/coretypes/resource.go](/pkg/types/coretypes/resource.go):
|
||||
|
||||
```go
|
||||
type Resource interface {
|
||||
Type() Type
|
||||
Kind() Kind
|
||||
Prefix(orgId valuer.UUID) string // metaresource:organization/<orgID>/dashboard
|
||||
Object(orgId valuer.UUID, selector string) string
|
||||
Scope(verb Verb) string // dashboard:read
|
||||
AllowedVerbs() []Verb
|
||||
}
|
||||
```
|
||||
|
||||
All resources are registered in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) using constructors like `NewResourceMetaResource(KindDashboard)` or `NewResourceTelemetryResource(KindLogs)`.
|
||||
|
||||
### Selector
|
||||
|
||||
A `Selector` identifies *which* instance(s) of a resource a check is about — a UUID, a role name, or the wildcard `*`. A `SelectorFunc` maps the extracted resource ID to selectors at request time. Two prebuilt ones cover most routes ([pkg/types/coretypes/selector.go](/pkg/types/coretypes/selector.go)):
|
||||
|
||||
- `WildcardSelector` — the check is against all instances of the resource (`create`, `list`).
|
||||
- `IDSelector` — the check is against the specific instance *or* the wildcard (`read`, `update`, `delete` of one object). A subject authorized on `*` is authorized on every instance.
|
||||
|
||||
When the ID in the request is not what FGA needs (e.g. routes receive a role UUID but FGA objects use role names), write a custom `SelectorFunc` — see `roleSelector` in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
|
||||
|
||||
### Roles, transactions, and tuples
|
||||
|
||||
SigNoz ships four managed roles, declared in [pkg/types/coretypes/registry_managed_role.go](/pkg/types/coretypes/registry_managed_role.go): `signoz-admin`, `signoz-editor`, `signoz-viewer`, and `signoz-anonymous`. Their permissions are declared in code as `Transaction`s (a verb on an object) in `ManagedRoleToTransactions` — this map is the single source of truth for what each managed role can do.
|
||||
|
||||
At organization bootstrap, `CreateManagedRoles` and `CreateManagedUserRoleTransactions` (see [pkg/authz/authz.go](/pkg/authz/authz.go)) persist the role rows and write one OpenFGA tuple per transaction, linking `role:organization/<orgID>/role/<name>#assignee` to each permitted object. Users and service accounts are then granted roles via `Grant`/`Revoke`, which writes `assignee` tuples. Custom roles (enterprise) are managed through the roles API in [pkg/authz/signozauthzapi/handler.go](/pkg/authz/signozauthzapi/handler.go).
|
||||
|
||||
### Schema
|
||||
|
||||
The OpenFGA authorization model is a hand-written DSL, embedded at build time: [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) for community and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) for enterprise. The community model only supports role assignment; the enterprise model defines per-verb relations on every type, enabling genuine per-resource checks. This split is why `CheckWithTupleCreation` behaves differently per edition (see [How does a check work at runtime?](#how-does-a-check-work-at-runtime)). You only touch these files when introducing a brand-new **type** — never for a new kind.
|
||||
|
||||
## How do I add authz to my feature?
|
||||
|
||||
### 1. Register the kind
|
||||
|
||||
Add your kind in [pkg/types/coretypes/registry_kind.go](/pkg/types/coretypes/registry_kind.go) and append it to `Kinds`:
|
||||
|
||||
```go
|
||||
KindThing = MustNewKind("thing")
|
||||
```
|
||||
|
||||
### 2. Register the resource
|
||||
|
||||
Add the resource in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) and append it to `Resources`:
|
||||
|
||||
```go
|
||||
ResourceMetaResourceThing = NewResourceMetaResource(KindThing)
|
||||
```
|
||||
|
||||
Pass an explicit verb list to `NewResourceMetaResource` only if your resource supports fewer verbs than the type default.
|
||||
|
||||
### 3. Grant permissions to managed roles
|
||||
|
||||
Decide what each managed role can do with your resource and add the transactions in [pkg/types/coretypes/registry_managed_role.go](/pkg/types/coretypes/registry_managed_role.go):
|
||||
|
||||
```go
|
||||
// thing — editors manage, viewers read
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindThing}, WildCardSelectorString)},
|
||||
```
|
||||
|
||||
The convention so far: admin gets everything, editor gets CRUD on day-to-day observability resources, viewer gets `read`/`list`, anonymous gets nothing (except public dashboards).
|
||||
|
||||
### 4. Wire the route
|
||||
|
||||
Register the route in [pkg/apiserver/signozapiserver](/pkg/apiserver/signozapiserver), wrapping your handler with `CheckResources` and declaring what the route touches via a `ResourceDef` ([pkg/http/handler/resourcedef.go](/pkg/http/handler/resourcedef.go)). A complete example from [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go):
|
||||
|
||||
```go
|
||||
router.Handle("/api/v1/service_accounts", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Create, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccount",
|
||||
// ...
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceServiceAccount,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost)
|
||||
```
|
||||
|
||||
The pieces:
|
||||
|
||||
- **`CheckResources(handlerFn, roles...)`** — the resource-aware authorization wrapper from [pkg/http/middleware/authz.go](/pkg/http/middleware/authz.go). The role list is the community-edition fallback: which managed roles may call this route when per-resource checks are unavailable.
|
||||
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
|
||||
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
|
||||
|
||||
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
|
||||
|
||||
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.
|
||||
|
||||
### 5. Backfill existing organizations (only if needed)
|
||||
|
||||
Managed-role tuples are written from the registry at organization creation, so **new resources need no migration for new organizations**. If existing organizations must get the new permissions, add a migration in [pkg/sqlmigration](/pkg/sqlmigration) that inserts the tuples — see [083_add_role_crud_tuples.go](/pkg/sqlmigration/083_add_role_crud_tuples.go) for the pattern.
|
||||
|
||||
## How does a check work at runtime?
|
||||
|
||||
1. The resource middleware ([pkg/http/middleware/resource.go](/pkg/http/middleware/resource.go)) runs on every request. It reads the matched handler's `ResourceDef`s, extracts the resource IDs from path/body, and stores the resolved resources in the request context.
|
||||
2. `CheckResources` reads them back, runs each `SelectorFunc`, and calls `AuthZ.CheckWithTupleCreation(ctx, claims, orgID, relation, resource, selectors, roleSelectors)`.
|
||||
3. What happens next depends on the edition:
|
||||
- **Community** ([pkg/authz/openfgaserver/server.go](/pkg/authz/openfgaserver/server.go)) ignores the resource and selectors and only checks whether the subject is an `assignee` of one of the allowed roles — a plain role gate.
|
||||
- **Enterprise** ([ee/authz/openfgaserver/server.go](/ee/authz/openfgaserver/server.go)) builds tuples via `authtypes.NewTuples` — subject `user:organization/<orgID>/user/<userID>`, relation `create`, object `serviceaccount:organization/<orgID>/serviceaccount/*` — and batch-checks them against OpenFGA: genuine per-resource authorization, including custom roles.
|
||||
|
||||
Because both paths go through the same middleware and the same `ResourceDef` declarations, a route wired once works correctly in both editions.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- Declare authz in the registries ([pkg/types/coretypes](/pkg/types/coretypes)), not in migrations — tuples for new organizations are derived from code at bootstrap.
|
||||
- A new kind never needs an OpenFGA schema change; only a new type does, and then **both** [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) must be updated together.
|
||||
- Prefer `CheckResources` + `ResourceDef` over the coarse `ViewAccess`/`EditAccess`/`AdminAccess` gates for new routes.
|
||||
- Use `WildcardSelector` for `create`/`list`, `IDSelector` for instance operations, and a custom `SelectorFunc` when the request ID is not the FGA selector.
|
||||
- Linking routes check **both** sides: peers via `AttachDetachSiblingResourceDef` (same verb on both), parent-child via a `BasicResourceDef` on the child (`create`/`delete`) paired with an `AttachDetachParentChildResourceDef` on the parent (`attach`/`detach`).
|
||||
- Changing `ManagedRoleToTransactions` only affects organizations created afterwards — add a [pkg/sqlmigration](/pkg/sqlmigration) migration to backfill existing ones.
|
||||
@@ -11,6 +11,7 @@ We adhere to three primary style guides as our foundation:
|
||||
We **recommend** (almost enforce) reviewing these guides before contributing to the codebase. They provide valuable insights into writing idiomatic Go code and will help you understand our approach to backend development. In addition, we have a few additional rules that make certain areas stricter than the above which can be found in area-specific files in this package:
|
||||
|
||||
- [Abstractions](abstractions.md) - When to introduce new types and intermediate representations
|
||||
- [Authz](authz.md) - Authorization, roles, and access control
|
||||
- [Errors](errors.md) - Structured error handling
|
||||
- [Endpoint](endpoint.md) - HTTP endpoint patterns
|
||||
- [Flagger](flagger.md) - Feature flag patterns
|
||||
|
||||
@@ -519,7 +519,7 @@ func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID,
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
_, err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationServiceAccount(ctx context.Cont
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
|
||||
}
|
||||
err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
_, err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
|
||||
}
|
||||
|
||||
@@ -295,6 +295,18 @@
|
||||
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
|
||||
"signoz/no-dashboard-fetch-outside-root": "error",
|
||||
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
|
||||
"signoz/no-raw-html-text-elements": "warn",
|
||||
// Bans raw span/p/h1-6 elements - use Typography component instead
|
||||
"signoz/no-multiple-components": "warn",
|
||||
// Bans multiple component exports per file - split into separate files
|
||||
"signoz/no-inline-helpers": "warn",
|
||||
// Bans too many helper functions in component files - move to utils.ts
|
||||
"signoz/max-component-lines": ["warn", { "max": 300 }],
|
||||
// Warns when component files exceed max lines - split into smaller components
|
||||
"signoz/no-getByText": "warn",
|
||||
// Bans getByText in tests - use getByTestId or getByRole instead
|
||||
"signoz/no-fireEvent": "warn",
|
||||
// Bans fireEvent in tests - use userEvent instead
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
|
||||
18
frontend/.oxlintrc.strict.json
Normal file
18
frontend/.oxlintrc.strict.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["./.oxlintrc.json"],
|
||||
"env": {
|
||||
"builtin": true,
|
||||
"es2021": true,
|
||||
"browser": true,
|
||||
"jest": true,
|
||||
"node": true
|
||||
},
|
||||
"rules": {
|
||||
"signoz/no-raw-html-text-elements": "error",
|
||||
"signoz/no-multiple-components": "error",
|
||||
"signoz/no-inline-helpers": "error",
|
||||
"signoz/no-getByText": "error",
|
||||
"signoz/no-fireEvent": "error"
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,17 @@ module.exports = {
|
||||
),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/prefer-css-variables.js'),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/class-name-pattern.js'),
|
||||
path.join(__dirname, 'stylelint-rules/no-font-properties.js'),
|
||||
path.join(__dirname, 'stylelint-rules/no-primitive-color-tokens.js'),
|
||||
path.join(__dirname, 'stylelint-rules/no-styles-scss.js'),
|
||||
],
|
||||
customSyntax: 'postcss-scss',
|
||||
rules: {
|
||||
// Applies to all SCSS files
|
||||
'local/no-unsupported-asset-url': true,
|
||||
'local/no-font-properties': [true, { severity: 'warning' }],
|
||||
'local/no-primitive-color-tokens': [true, { severity: 'warning' }],
|
||||
'local/no-styles-scss': [true, { severity: 'warning' }],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
14
frontend/.stylelintrc.strict.cjs
Normal file
14
frontend/.stylelintrc.strict.cjs
Normal file
@@ -0,0 +1,14 @@
|
||||
// Strict config for new files - rules that are warnings in base config become errors here
|
||||
// oxlint-disable-next-line typescript/no-require-imports
|
||||
const baseConfig = require('./.stylelintrc.cjs');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
rules: {
|
||||
...baseConfig.rules,
|
||||
// Override warnings to errors for new files
|
||||
'local/no-font-properties': true, // error (no severity = error)
|
||||
'local/no-primitive-color-tokens': true,
|
||||
'local/no-styles-scss': true,
|
||||
},
|
||||
};
|
||||
@@ -25,6 +25,7 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Never create barrel files.
|
||||
- When writing new css, prefer CSS Modules
|
||||
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
|
||||
- When writing code that could need authorization checks, read ./src/lib/authz/README.md
|
||||
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
- Run `pnpm tsgo --noEmit`
|
||||
|
||||
3
frontend/__mocks__/lib/env.ts
Normal file
3
frontend/__mocks__/lib/env.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const IS_DEV = false;
|
||||
export const IS_PROD = true;
|
||||
export const MODE = 'test';
|
||||
@@ -29,6 +29,7 @@ const config: Config.InitialOptions = {
|
||||
'^constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',
|
||||
'^lib/env$': '<rootDir>/__mocks__/lib/env.ts',
|
||||
'^test-mocks/(.*)$': '<rootDir>/__mocks__/$1',
|
||||
'^react-syntax-highlighter/dist/esm/(.*)$':
|
||||
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"lint:generated": "oxlint ./src/api/generated --fix",
|
||||
"lint:fix": "oxlint ./src --fix",
|
||||
"lint:styles": "stylelint \"src/**/*.scss\"",
|
||||
"lint:strict": "oxlint -c .oxlintrc.strict.json",
|
||||
"lint:styles:strict": "stylelint --config .stylelintrc.strict.cjs",
|
||||
"jest": "jest",
|
||||
"jest:coverage": "jest --coverage",
|
||||
"jest:watch": "jest --watch",
|
||||
|
||||
71
frontend/plugins/rules/max-component-lines.mjs
Normal file
71
frontend/plugins/rules/max-component-lines.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Rule: max-component-lines
|
||||
*
|
||||
* Warns when a component file exceeds configured line limit.
|
||||
* Large components should be split into smaller ones.
|
||||
*
|
||||
* Config: ["warn", { "max": 300 }]
|
||||
* Default: 300 lines
|
||||
*
|
||||
* This rule should always be "warn" (never "error").
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_LINES = 300;
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
docs: {
|
||||
description: 'Warn when component files exceed a line limit',
|
||||
category: 'Code Quality',
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
max: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
create(context) {
|
||||
const options = context.options?.[0] || {};
|
||||
const maxLines = options.max || DEFAULT_MAX_LINES;
|
||||
const filename = context.getFilename();
|
||||
|
||||
// Only check .tsx files (component files)
|
||||
if (!/\.tsx$/.test(filename)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Skip test files
|
||||
if (/\.(test|spec)\.tsx$/.test(filename)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Skip utility/type files
|
||||
if (
|
||||
/\/(utils|helpers|constants|types|hooks)\.(ts|tsx)$/.test(filename) ||
|
||||
/\/(utils|helpers|constants|types)\//.test(filename)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
const sourceCode = context.getSourceCode();
|
||||
const lines = sourceCode.lines?.length || sourceCode.getText().split('\n').length;
|
||||
|
||||
if (lines > maxLines) {
|
||||
context.report({
|
||||
node,
|
||||
message: `Component file has ${lines} lines (max ${maxLines}). Consider splitting into smaller components or extracting logic into hooks/utils.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
48
frontend/plugins/rules/no-fireEvent.mjs
Normal file
48
frontend/plugins/rules/no-fireEvent.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Rule: no-fireEvent
|
||||
*
|
||||
* Bans fireEvent in test files. Use userEvent instead for more realistic
|
||||
* user interaction simulation.
|
||||
*
|
||||
* BAD:
|
||||
* fireEvent.click(button)
|
||||
* fireEvent.change(input, { target: { value: 'test' } })
|
||||
*
|
||||
* GOOD:
|
||||
* await userEvent.click(button)
|
||||
* await userEvent.type(input, 'test')
|
||||
*/
|
||||
|
||||
export default {
|
||||
create(context) {
|
||||
return {
|
||||
// fireEvent.click(...), fireEvent.change(...)
|
||||
MemberExpression(node) {
|
||||
if (
|
||||
node.object.type === 'Identifier' &&
|
||||
node.object.name === 'fireEvent'
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
"Avoid 'fireEvent'. Use userEvent from @testing-library/user-event for more realistic user interactions.",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// import { fireEvent } from '@testing-library/react'
|
||||
ImportSpecifier(node) {
|
||||
if (
|
||||
node.imported.type === 'Identifier' &&
|
||||
node.imported.name === 'fireEvent'
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
"Avoid importing 'fireEvent'. Use userEvent from @testing-library/user-event instead.",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
53
frontend/plugins/rules/no-getByText.mjs
Normal file
53
frontend/plugins/rules/no-getByText.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Rule: no-getByText
|
||||
*
|
||||
* Bans getByText, queryByText, findByText, getAllByText, queryAllByText, findAllByText
|
||||
* in test files. Use getByTestId or getByRole instead.
|
||||
*
|
||||
* BAD:
|
||||
* screen.getByText('Submit')
|
||||
* getByText('Submit')
|
||||
*
|
||||
* GOOD:
|
||||
* screen.getByTestId('submit-button')
|
||||
* screen.getByRole('button', { name: 'Submit' })
|
||||
*/
|
||||
|
||||
const BANNED_METHODS = new Set([
|
||||
'getByText',
|
||||
'queryByText',
|
||||
'findByText',
|
||||
'getAllByText',
|
||||
'queryAllByText',
|
||||
'findAllByText',
|
||||
]);
|
||||
|
||||
export default {
|
||||
create(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
let methodName = null;
|
||||
|
||||
// screen.getByText(...) or result.getByText(...)
|
||||
if (
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
node.callee.property.type === 'Identifier'
|
||||
) {
|
||||
methodName = node.callee.property.name;
|
||||
}
|
||||
|
||||
// Direct getByText(...) from destructured import
|
||||
if (node.callee.type === 'Identifier') {
|
||||
methodName = node.callee.name;
|
||||
}
|
||||
|
||||
if (methodName && BANNED_METHODS.has(methodName)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `Avoid '${methodName}'. Use getByTestId or getByRole instead for more stable selectors.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
161
frontend/plugins/rules/no-inline-helpers.mjs
Normal file
161
frontend/plugins/rules/no-inline-helpers.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Rule: no-inline-helpers
|
||||
*
|
||||
* Bans too many helper functions in component files.
|
||||
* Helper functions should be moved to utils.ts or specialized files.
|
||||
*
|
||||
* Exempt:
|
||||
* - Hooks (use* functions)
|
||||
* - Exported functions (they're intentionally in the file)
|
||||
* - Files named utils.ts, helpers.ts, constants.ts
|
||||
*
|
||||
* Threshold: More than 3 private helper functions triggers warning.
|
||||
*/
|
||||
|
||||
const MAX_HELPERS = 3;
|
||||
|
||||
function isHookName(name) {
|
||||
return /^use[A-Z]/.test(name);
|
||||
}
|
||||
|
||||
function isComponentName(name) {
|
||||
return /^[A-Z]/.test(name);
|
||||
}
|
||||
|
||||
export default {
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
|
||||
// Skip utility files
|
||||
if (
|
||||
/\/(utils|helpers|constants|types|hooks)\.(ts|tsx|js|jsx)$/.test(filename) ||
|
||||
/\/(utils|helpers|constants|types)\//.test(filename)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Skip test files
|
||||
if (/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(filename)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const privateHelpers = [];
|
||||
const exportedNames = new Set();
|
||||
let hasComponent = false;
|
||||
|
||||
return {
|
||||
// Track exported names
|
||||
ExportNamedDeclaration(node) {
|
||||
if (node.declaration) {
|
||||
if (node.declaration.type === 'FunctionDeclaration' && node.declaration.id) {
|
||||
exportedNames.add(node.declaration.id.name);
|
||||
if (isComponentName(node.declaration.id.name)) {
|
||||
hasComponent = true;
|
||||
}
|
||||
}
|
||||
if (node.declaration.type === 'VariableDeclaration') {
|
||||
for (const decl of node.declaration.declarations) {
|
||||
if (decl.id.type === 'Identifier') {
|
||||
exportedNames.add(decl.id.name);
|
||||
if (isComponentName(decl.id.name)) {
|
||||
hasComponent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
ExportDefaultDeclaration(node) {
|
||||
if (
|
||||
node.declaration.type === 'FunctionDeclaration' &&
|
||||
node.declaration.id &&
|
||||
isComponentName(node.declaration.id.name)
|
||||
) {
|
||||
hasComponent = true;
|
||||
}
|
||||
if (node.declaration.type === 'Identifier') {
|
||||
exportedNames.add(node.declaration.name);
|
||||
}
|
||||
},
|
||||
|
||||
// Track private function declarations
|
||||
FunctionDeclaration(node) {
|
||||
if (!node.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = node.id.name;
|
||||
|
||||
// Will check if exported later
|
||||
if (
|
||||
!isHookName(name) &&
|
||||
!isComponentName(name) &&
|
||||
node.parent.type !== 'ExportNamedDeclaration' &&
|
||||
node.parent.type !== 'ExportDefaultDeclaration'
|
||||
) {
|
||||
privateHelpers.push({ name, node });
|
||||
}
|
||||
},
|
||||
|
||||
// Track private arrow/function expressions
|
||||
VariableDeclaration(node) {
|
||||
// Skip if this is an export
|
||||
if (
|
||||
node.parent.type === 'ExportNamedDeclaration' ||
|
||||
node.parent.type === 'Program'
|
||||
) {
|
||||
for (const decl of node.declarations) {
|
||||
if (decl.id.type !== 'Identifier') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = decl.id.name;
|
||||
const init = decl.init;
|
||||
|
||||
// Only track functions
|
||||
if (
|
||||
!init ||
|
||||
(init.type !== 'ArrowFunctionExpression' &&
|
||||
init.type !== 'FunctionExpression')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip hooks, components, and exports
|
||||
if (
|
||||
!isHookName(name) &&
|
||||
!isComponentName(name) &&
|
||||
node.parent.type !== 'ExportNamedDeclaration'
|
||||
) {
|
||||
privateHelpers.push({ name, node: decl });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
// Only check files with components
|
||||
if (!hasComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out any that ended up exported
|
||||
const actualPrivate = privateHelpers.filter(
|
||||
(h) => !exportedNames.has(h.name),
|
||||
);
|
||||
|
||||
if (actualPrivate.length > MAX_HELPERS) {
|
||||
// Report on helpers beyond the threshold
|
||||
for (let i = MAX_HELPERS; i < actualPrivate.length; i++) {
|
||||
const { name, node } = actualPrivate[i];
|
||||
context.report({
|
||||
node,
|
||||
message: `Too many helper functions (${actualPrivate.length}) in component file. Move '${name}' and other helpers to utils.ts. Helpers: ${actualPrivate.map((h) => h.name).join(', ')}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
142
frontend/plugins/rules/no-multiple-components.mjs
Normal file
142
frontend/plugins/rules/no-multiple-components.mjs
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Rule: no-multiple-components
|
||||
*
|
||||
* Bans multiple React component exports in a single file.
|
||||
* Each component should have its own file.
|
||||
*
|
||||
* BAD:
|
||||
* export function ComponentA() { return <div /> }
|
||||
* export function ComponentB() { return <div /> }
|
||||
*
|
||||
* GOOD:
|
||||
* // ComponentA.tsx
|
||||
* export function ComponentA() { return <div /> }
|
||||
*
|
||||
* // ComponentB.tsx (separate file)
|
||||
* export function ComponentB() { return <div /> }
|
||||
*
|
||||
* Note: index.tsx files are exempt (re-export barrels).
|
||||
*/
|
||||
|
||||
function isPascalCase(name) {
|
||||
return /^[A-Z][a-zA-Z0-9]*$/.test(name);
|
||||
}
|
||||
|
||||
function hasJSXReturn(node) {
|
||||
if (!node.body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Arrow function with direct JSX return: () => <div />
|
||||
if (node.body.type === 'JSXElement' || node.body.type === 'JSXFragment') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Function body - check return statements
|
||||
if (node.body.type === 'BlockStatement') {
|
||||
for (const stmt of node.body.body) {
|
||||
if (
|
||||
stmt.type === 'ReturnStatement' &&
|
||||
stmt.argument &&
|
||||
(stmt.argument.type === 'JSXElement' ||
|
||||
stmt.argument.type === 'JSXFragment' ||
|
||||
// Conditional: condition ? <A /> : <B />
|
||||
(stmt.argument.type === 'ConditionalExpression' &&
|
||||
(stmt.argument.consequent.type === 'JSXElement' ||
|
||||
stmt.argument.alternate.type === 'JSXElement')) ||
|
||||
// Logical: condition && <A />
|
||||
(stmt.argument.type === 'LogicalExpression' &&
|
||||
(stmt.argument.left.type === 'JSXElement' ||
|
||||
stmt.argument.right.type === 'JSXElement')))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export default {
|
||||
create(context) {
|
||||
const exportedComponents = [];
|
||||
|
||||
return {
|
||||
// export function ComponentName() { }
|
||||
ExportNamedDeclaration(node) {
|
||||
if (!node.declaration) {
|
||||
return;
|
||||
}
|
||||
|
||||
// function ComponentName() {}
|
||||
if (node.declaration.type === 'FunctionDeclaration') {
|
||||
const name = node.declaration.id?.name;
|
||||
if (name && isPascalCase(name) && hasJSXReturn(node.declaration)) {
|
||||
exportedComponents.push({ name, node });
|
||||
}
|
||||
}
|
||||
|
||||
// const ComponentName = () => {} or const ComponentName = function() {}
|
||||
if (node.declaration.type === 'VariableDeclaration') {
|
||||
for (const declarator of node.declaration.declarations) {
|
||||
const name = declarator.id?.name;
|
||||
if (!name || !isPascalCase(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const init = declarator.init;
|
||||
if (!init) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Arrow function or function expression
|
||||
if (
|
||||
(init.type === 'ArrowFunctionExpression' ||
|
||||
init.type === 'FunctionExpression') &&
|
||||
hasJSXReturn(init)
|
||||
) {
|
||||
exportedComponents.push({ name, node });
|
||||
}
|
||||
|
||||
// React.memo(Component) or memo(Component)
|
||||
if (
|
||||
init.type === 'CallExpression' &&
|
||||
((init.callee.type === 'Identifier' &&
|
||||
(init.callee.name === 'memo' ||
|
||||
init.callee.name === 'forwardRef')) ||
|
||||
(init.callee.type === 'MemberExpression' &&
|
||||
init.callee.object.name === 'React' &&
|
||||
(init.callee.property.name === 'memo' ||
|
||||
init.callee.property.name === 'forwardRef')))
|
||||
) {
|
||||
exportedComponents.push({ name, node });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// export default function ComponentName() { }
|
||||
ExportDefaultDeclaration(node) {
|
||||
if (node.declaration.type === 'FunctionDeclaration') {
|
||||
const name = node.declaration.id?.name;
|
||||
if (name && isPascalCase(name) && hasJSXReturn(node.declaration)) {
|
||||
exportedComponents.push({ name, node });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (exportedComponents.length > 1) {
|
||||
// Report on all but the first component
|
||||
for (let i = 1; i < exportedComponents.length; i++) {
|
||||
const { name, node } = exportedComponents[i];
|
||||
context.report({
|
||||
node,
|
||||
message: `Multiple component exports in same file. Move '${name}' to its own file. Found ${exportedComponents.length} components: ${exportedComponents.map((c) => c.name).join(', ')}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
39
frontend/plugins/rules/no-raw-html-text-elements.mjs
Normal file
39
frontend/plugins/rules/no-raw-html-text-elements.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Rule: no-raw-html-text-elements
|
||||
*
|
||||
* Bans raw HTML text elements (span, p, h1-h6) in JSX.
|
||||
* Use Typography component from @signozhq/ui instead.
|
||||
*
|
||||
* BAD:
|
||||
* <span>Hello</span>
|
||||
* <p>Paragraph</p>
|
||||
* <h1>Title</h1>
|
||||
*
|
||||
* GOOD:
|
||||
* <Typography>Hello</Typography>
|
||||
* <Typography.Text>Hello</Typography.Text>
|
||||
* <Typography.Title level={1}>Title</Typography.Title>
|
||||
*/
|
||||
|
||||
const BANNED_ELEMENTS = new Set(['span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
||||
|
||||
export default {
|
||||
create(context) {
|
||||
return {
|
||||
JSXOpeningElement(node) {
|
||||
if (node.name.type !== 'JSXIdentifier') {
|
||||
return;
|
||||
}
|
||||
|
||||
const elementName = node.name.name;
|
||||
|
||||
if (BANNED_ELEMENTS.has(elementName)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `Avoid raw '<${elementName}>' element. Use Typography component from @signozhq/ui instead.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -13,6 +13,12 @@ import noAntdComponents from './rules/no-antd-components.mjs';
|
||||
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
|
||||
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
|
||||
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
|
||||
import noGetByText from './rules/no-getByText.mjs';
|
||||
import noFireEvent from './rules/no-fireEvent.mjs';
|
||||
import noRawHtmlTextElements from './rules/no-raw-html-text-elements.mjs';
|
||||
import noMultipleComponents from './rules/no-multiple-components.mjs';
|
||||
import noInlineHelpers from './rules/no-inline-helpers.mjs';
|
||||
import maxComponentLines from './rules/max-component-lines.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -27,5 +33,11 @@ export default {
|
||||
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
|
||||
'no-css-module-bracket-access': noCssModuleBracketAccess,
|
||||
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
|
||||
'no-getByText': noGetByText,
|
||||
'no-fireEvent': noFireEvent,
|
||||
'no-raw-html-text-elements': noRawHtmlTextElements,
|
||||
'no-multiple-components': noMultipleComponents,
|
||||
'no-inline-helpers': noInlineHelpers,
|
||||
'max-component-lines': maxComponentLines,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,12 +22,16 @@ import type {
|
||||
CreateServiceAccountKey201,
|
||||
CreateServiceAccountKeyPathParameters,
|
||||
CreateServiceAccountRole201,
|
||||
CreateServiceAccountRolePathParameters,
|
||||
CreateServiceAccountRoleDeprecated201,
|
||||
CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
DeleteServiceAccountPathParameters,
|
||||
DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
DeleteServiceAccountRolePathParameters,
|
||||
GetMyServiceAccount200,
|
||||
GetServiceAccount200,
|
||||
GetServiceAccountPathParameters,
|
||||
GetServiceAccountRole200,
|
||||
GetServiceAccountRolePathParameters,
|
||||
GetServiceAccountRoles200,
|
||||
GetServiceAccountRolesPathParameters,
|
||||
ListServiceAccountKeys200,
|
||||
@@ -35,6 +39,7 @@ import type {
|
||||
ListServiceAccounts200,
|
||||
RenderErrorResponseDTO,
|
||||
RevokeServiceAccountKeyPathParameters,
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
ServiceaccounttypesPostableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesPostableServiceAccountDTO,
|
||||
ServiceaccounttypesPostableServiceAccountRoleDTO,
|
||||
@@ -46,6 +51,272 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a service account
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const createServiceAccountRole = (
|
||||
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountRole201>({
|
||||
url: `/api/v1/service_account_roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesPostableServiceAccountRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccountRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createServiceAccountRole(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>
|
||||
>;
|
||||
export type CreateServiceAccountRoleMutationBody =
|
||||
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
|
||||
| undefined;
|
||||
export type CreateServiceAccountRoleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const useCreateServiceAccountRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from a service account
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const deleteServiceAccountRole = (
|
||||
{ id }: DeleteServiceAccountRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_account_roles/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceAccountRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteServiceAccountRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteServiceAccountRole(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceAccountRoleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const useDeleteServiceAccountRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint gets an existing service account role
|
||||
* @summary Get service account role
|
||||
*/
|
||||
export const getServiceAccountRole = (
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetServiceAccountRole200>({
|
||||
url: `/api/v1/service_account_roles/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetServiceAccountRoleQueryKey = ({
|
||||
id,
|
||||
}: GetServiceAccountRolePathParameters) => {
|
||||
return [`/api/v1/service_account_roles/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetServiceAccountRoleQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetServiceAccountRoleQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>
|
||||
> = ({ signal }) => getServiceAccountRole({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetServiceAccountRoleQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>
|
||||
>;
|
||||
export type GetServiceAccountRoleQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get service account role
|
||||
*/
|
||||
|
||||
export function useGetServiceAccountRole<
|
||||
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetServiceAccountRoleQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get service account role
|
||||
*/
|
||||
export const invalidateGetServiceAccountRole = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetServiceAccountRoleQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists the service accounts for an organisation
|
||||
* @summary List service accounts
|
||||
@@ -984,45 +1255,46 @@ export const invalidateGetServiceAccountRoles = async (
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a service account
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const createServiceAccountRole = (
|
||||
{ id }: CreateServiceAccountRolePathParameters,
|
||||
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
|
||||
export const createServiceAccountRoleDeprecated = (
|
||||
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountRole201>({
|
||||
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
|
||||
url: `/api/v1/service_accounts/${id}/roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesPostableServiceAccountRoleDTO,
|
||||
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountRoleMutationOptions = <
|
||||
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccountRole'];
|
||||
const mutationKey = ['createServiceAccountRoleDeprecated'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -1032,62 +1304,66 @@ export const getCreateServiceAccountRoleMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return createServiceAccountRole(pathParams, data);
|
||||
return createServiceAccountRoleDeprecated(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>
|
||||
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
|
||||
>;
|
||||
export type CreateServiceAccountRoleMutationBody =
|
||||
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
|
||||
export type CreateServiceAccountRoleDeprecatedMutationBody =
|
||||
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
|
||||
| undefined;
|
||||
export type CreateServiceAccountRoleMutationError =
|
||||
export type CreateServiceAccountRoleDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const useCreateServiceAccountRole = <
|
||||
export const useCreateServiceAccountRoleDeprecated = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
|
||||
return useMutation(
|
||||
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from service account
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const deleteServiceAccountRole = (
|
||||
{ id, rid }: DeleteServiceAccountRolePathParameters,
|
||||
export const deleteServiceAccountRoleDeprecated = (
|
||||
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
@@ -1097,23 +1373,23 @@ export const deleteServiceAccountRole = (
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceAccountRoleMutationOptions = <
|
||||
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteServiceAccountRole'];
|
||||
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -1123,44 +1399,47 @@ export const getDeleteServiceAccountRoleMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters }
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteServiceAccountRole(pathParams);
|
||||
return deleteServiceAccountRoleDeprecated(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceAccountRoleMutationError =
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const useDeleteServiceAccountRole = <
|
||||
export const useDeleteServiceAccountRoleDeprecated = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
|
||||
return useMutation(
|
||||
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* This endpoint gets my service account
|
||||
|
||||
@@ -2048,6 +2048,13 @@ export interface AuthtypesAuthNSupportDTO {
|
||||
password?: AuthtypesPasswordAuthNSupportDTO[] | null;
|
||||
}
|
||||
|
||||
export interface AuthtypesDeprecatedPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableAuthDomainDTO {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
@@ -2287,13 +2294,6 @@ export interface AuthtypesPostableRotateTokenDTO {
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2310,7 +2310,18 @@ export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
userRoles?: AuthtypesPostableUserRoleDTO[];
|
||||
userRoles?: AuthtypesDeprecatedPostableUserRoleDTO[];
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
roleId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
@@ -5693,6 +5704,81 @@ export interface InframonitoringtypesPodCountsByPhaseDTO {
|
||||
unknown: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPodCountsByStatusDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
completed: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
containerCannotRun: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
containerCreating: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
crashLoopBackOff: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
createContainerConfigError: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
errImagePull: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
error: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
evicted: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
failed: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
imagePullBackOff: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
nodeAffinity: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
nodeLost: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
oomKilled: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
pending: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
running: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
shutdown: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
unexpectedAdmissionError: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
unknown: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesClusterRecordDTO {
|
||||
/**
|
||||
* @type number
|
||||
@@ -5724,6 +5810,7 @@ export interface InframonitoringtypesClusterRecordDTO {
|
||||
meta: InframonitoringtypesClusterRecordDTOMeta;
|
||||
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesResponseTypeDTO {
|
||||
@@ -5827,6 +5914,7 @@ export interface InframonitoringtypesDaemonSetRecordDTO {
|
||||
*/
|
||||
meta: InframonitoringtypesDaemonSetRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesDaemonSetsDTO {
|
||||
@@ -5904,6 +5992,7 @@ export interface InframonitoringtypesDeploymentRecordDTO {
|
||||
*/
|
||||
meta: InframonitoringtypesDeploymentRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesDeploymentsDTO {
|
||||
@@ -6070,6 +6159,7 @@ export interface InframonitoringtypesJobRecordDTO {
|
||||
*/
|
||||
meta: InframonitoringtypesJobRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -6123,6 +6213,7 @@ export interface InframonitoringtypesNamespaceRecordDTO {
|
||||
*/
|
||||
namespaceName: string;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesNamespacesDTO {
|
||||
@@ -6189,6 +6280,7 @@ export interface InframonitoringtypesNodeRecordDTO {
|
||||
*/
|
||||
nodeName: string;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesNodesDTO {
|
||||
@@ -6226,6 +6318,27 @@ export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
|
||||
export type InframonitoringtypesPodRecordDTOMeta =
|
||||
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
|
||||
|
||||
export enum InframonitoringtypesPodStatusDTO {
|
||||
pending = 'pending',
|
||||
running = 'running',
|
||||
failed = 'failed',
|
||||
unknown = 'unknown',
|
||||
crashloopbackoff = 'crashloopbackoff',
|
||||
imagepullbackoff = 'imagepullbackoff',
|
||||
errimagepull = 'errimagepull',
|
||||
createcontainerconfigerror = 'createcontainerconfigerror',
|
||||
containercreating = 'containercreating',
|
||||
oomkilled = 'oomkilled',
|
||||
completed = 'completed',
|
||||
error = 'error',
|
||||
containercannotrun = 'containercannotrun',
|
||||
evicted = 'evicted',
|
||||
nodeaffinity = 'nodeaffinity',
|
||||
nodelost = 'nodelost',
|
||||
shutdown = 'shutdown',
|
||||
unexpectedadmissionerror = 'unexpectedadmissionerror',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export interface InframonitoringtypesPodRecordDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
@@ -6252,6 +6365,7 @@ export interface InframonitoringtypesPodRecordDTO {
|
||||
*/
|
||||
podCPURequest: number;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
@@ -6268,6 +6382,12 @@ export interface InframonitoringtypesPodRecordDTO {
|
||||
*/
|
||||
podMemoryRequest: number;
|
||||
podPhase: InframonitoringtypesPodPhaseDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
podRestarts: number;
|
||||
podStatus: InframonitoringtypesPodStatusDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6585,6 +6705,7 @@ export interface InframonitoringtypesStatefulSetRecordDTO {
|
||||
*/
|
||||
meta: InframonitoringtypesStatefulSetRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
@@ -8508,6 +8629,13 @@ export interface RuletypesRuleDTO {
|
||||
export enum RuletypesThresholdKindDTO {
|
||||
basic = 'basic',
|
||||
}
|
||||
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -8577,7 +8705,11 @@ export interface ServiceaccounttypesPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
roleId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
serviceAccountId: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountDTO {
|
||||
@@ -10343,6 +10475,28 @@ export type UpdateRoutePolicy200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccountRole200 = {
|
||||
data: ServiceaccounttypesServiceAccountRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListServiceAccounts200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -10426,10 +10580,10 @@ export type GetServiceAccountRoles200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRolePathParameters = {
|
||||
export type CreateServiceAccountRoleDeprecatedPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CreateServiceAccountRole201 = {
|
||||
export type CreateServiceAccountRoleDeprecated201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -10437,7 +10591,7 @@ export type CreateServiceAccountRole201 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRolePathParameters = {
|
||||
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
|
||||
id: string;
|
||||
rid: string;
|
||||
};
|
||||
@@ -11597,6 +11751,28 @@ export type RotateSession200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateUserRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteUserRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetUserRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetUserRole200 = {
|
||||
data: AuthtypesUserRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUsers200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -19,12 +19,15 @@ import type {
|
||||
|
||||
import type {
|
||||
AuthtypesPostableUserDTO,
|
||||
AuthtypesPostableUserRoleDTO,
|
||||
CreateInvite201,
|
||||
CreateResetPasswordToken201,
|
||||
CreateResetPasswordTokenPathParameters,
|
||||
CreateUser201,
|
||||
CreateUserRole201,
|
||||
DeleteUserDeprecatedPathParameters,
|
||||
DeleteUserPathParameters,
|
||||
DeleteUserRolePathParameters,
|
||||
GetMyUser200,
|
||||
GetMyUserDeprecated200,
|
||||
GetResetPasswordToken200,
|
||||
@@ -37,6 +40,8 @@ import type {
|
||||
GetUserDeprecated200,
|
||||
GetUserDeprecatedPathParameters,
|
||||
GetUserPathParameters,
|
||||
GetUserRole200,
|
||||
GetUserRolePathParameters,
|
||||
GetUsersByRoleID200,
|
||||
GetUsersByRoleIDPathParameters,
|
||||
ListUsers200,
|
||||
@@ -1154,6 +1159,267 @@ export const invalidateGetUsersByRoleID = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a user
|
||||
* @summary Create user role
|
||||
*/
|
||||
export const createUserRole = (
|
||||
authtypesPostableUserRoleDTO?: BodyType<AuthtypesPostableUserRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateUserRole201>({
|
||||
url: `/api/v2/user_roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPostableUserRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateUserRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createUserRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createUserRole(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateUserRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createUserRole>>
|
||||
>;
|
||||
export type CreateUserRoleMutationBody =
|
||||
| BodyType<AuthtypesPostableUserRoleDTO>
|
||||
| undefined;
|
||||
export type CreateUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create user role
|
||||
*/
|
||||
export const useCreateUserRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateUserRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from a user
|
||||
* @summary Delete user role
|
||||
*/
|
||||
export const deleteUserRole = (
|
||||
{ id }: DeleteUserRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/user_roles/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteUserRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteUserRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
{ pathParams: DeleteUserRolePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteUserRole(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteUserRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>
|
||||
>;
|
||||
|
||||
export type DeleteUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete user role
|
||||
*/
|
||||
export const useDeleteUserRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteUserRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint gets an existing user role
|
||||
* @summary Get user role
|
||||
*/
|
||||
export const getUserRole = (
|
||||
{ id }: GetUserRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetUserRole200>({
|
||||
url: `/api/v2/user_roles/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetUserRoleQueryKey = ({ id }: GetUserRolePathParameters) => {
|
||||
return [`/api/v2/user_roles/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetUserRoleQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetUserRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetUserRoleQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getUserRole>>> = ({
|
||||
signal,
|
||||
}) => getUserRole({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetUserRoleQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getUserRole>>
|
||||
>;
|
||||
export type GetUserRoleQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get user role
|
||||
*/
|
||||
|
||||
export function useGetUserRole<
|
||||
TData = Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetUserRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetUserRoleQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get user role
|
||||
*/
|
||||
export const invalidateGetUserRole = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetUserRolePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetUserRoleQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists all users for the organization
|
||||
* @summary List users v2
|
||||
@@ -1865,6 +2131,7 @@ export const invalidateGetRolesByUserID = async (
|
||||
|
||||
/**
|
||||
* This endpoint assigns the role to the user roles by user id
|
||||
* @deprecated
|
||||
* @summary Set user roles
|
||||
*/
|
||||
export const setRoleByUserID = (
|
||||
@@ -1936,6 +2203,7 @@ export type SetRoleByUserIDMutationBody =
|
||||
export type SetRoleByUserIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Set user roles
|
||||
*/
|
||||
export const useSetRoleByUserID = <
|
||||
@@ -1964,6 +2232,7 @@ export const useSetRoleByUserID = <
|
||||
};
|
||||
/**
|
||||
* This endpoint removes a role from the user by user id and role id
|
||||
* @deprecated
|
||||
* @summary Remove a role from user
|
||||
*/
|
||||
export const removeUserRoleByUserIDAndRoleID = (
|
||||
@@ -2022,6 +2291,7 @@ export type RemoveUserRoleByUserIDAndRoleIDMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Remove a role from user
|
||||
*/
|
||||
export const useRemoveUserRoleByUserIDAndRoleID = <
|
||||
|
||||
1
frontend/src/assets/Logos/cassandra.svg
Normal file
1
frontend/src/assets/Logos/cassandra.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#1287b1" viewBox="0 0 24 24"><title>Apache Cassandra</title><path d="M10.374 10.53a3 3 0 0 1-.428-.222l.555.143c0 .02-.01.036-.01.055l-.117.025zm-.283 1.506-.315.253.852-1.079-1.078.391c.002.017.009.033.009.05a.57.57 0 0 1-.184.42q.154.327.375.616a3.2 3.2 0 0 1 .34-.651zm.717-2.347-.652-.82a.43.43 0 0 1-.506.162c-.054.073-.083.162-.13.24l1.258.463zm-1.666.444c-.07.314-.087.637-.05.956a.57.57 0 0 1 .451.475l.946-.606c-.067-.022-.126-.06-.191-.088l-1.119-.08.64-.14a3.2 3.2 0 0 1-.668-.554zM20.1 11.648c-.164.202.833 1.022.833 1.022s-1.654-1.022-2.234-.72c-.278.144.574.811 1.175 1.242-.428-.274-.982-.571-1.175-.408-.328.277 1.565 2.549 1.565 2.549s-2.145-2.322-2.36-2.209c-.214.114.593 1.224.593 1.224s-1.06-1.16-1.35-.959c-.29.202 1.514 3.218 1.514 3.218s-1.956-3.091-2.763-2.574c1.268 2.782.795 3.18.795 3.18s-.162-2.839-1.742-2.764c-.795.038.379 2.12.379 2.12s-1.08-1.902-1.8-1.864c1.326 2.51.854 3.53.854 3.53s.219-2.143-1.58-3.336c.682.606-.427 3.336-.427 3.336s.976-4.023-.719-3.256c-.268.121-.019 2.007-.019 2.007s-.34-2.158-.851-2.045c-.298.066-1.893 2.99-1.893 2.99s1.306-3.16.908-3.027c-.29.096-.833 1.4-.833 1.4s.265-1.287 0-1.363c-.264-.075-1.74 1.363-1.74 1.363s1.097-1.287.908-1.552c-.287-.402-.623-.42-1.022-.265-.581.226-1.363 1.287-1.363 1.287s.78-1.074.643-1.476c-.219-.647-2.46 1.249-2.46 1.249s1.325-1.25 1.022-1.514c-.303-.265-1.947-.183-2.46-.185-1.515-.004-2.039-.36-2.498-.724 1.987.997 3.803-.151 6.094.494l.21.06c-1.3-.558-2.144-1.378-2.226-2.354-.036-.416.074-.827.297-1.222.619-.4 1.29-.773 2.06-1.095a4 4 0 0 0-.064.698c0 2.44 2.203 4.417 4.92 4.417s4.92-1.977 4.92-4.417c0-.45-.083-.881-.223-1.29 1.431.404 2.45.968 3.132 1.335.022.092.045.184.053.279.024.274-.018.547-.11.814.095-.147.198-.288.28-.445.367-.997 1.855.227 1.855.227s-1.085-.454-1.06-.24c.026.215 1.628.96 1.628.96s-1.45-.455-1.362-.114c.088.34 1.817 1.703 1.817 1.703s-1.956-1.489-2.12-1.287zm-7.268 2.65.042-.008-.06.01zM9.256 9.753c.12.13.26.234.396.343l.927-.029-1.064-.788c-.093.154-.195.303-.26.474Zm10.62 3.44c.3.215.54.373.54.373s-.24-.181-.54-.374zM7.507 8.617c-.14.229-.214.492-.215.76a3.99 3.99 0 0 0 2.358 3.64q0-.008.003-.014a3.2 3.2 0 0 1-.58-.788c-.648.099-.926-.794-.336-1.08a3.2 3.2 0 0 1 .138-1.388 3.16 3.16 0 0 1-.52-1.36q-.444.105-.848.23m1.488.82q.163-.36.402-.661a.435.435 0 0 1 .568-.557c.077-.059.166-.099.248-.15a16 16 0 0 0-1.727.284c.114.388.272.76.509 1.084m2.285 3.928c1.4 0 2.633-.723 3.344-1.816a3.4 3.4 0 0 0-1.265-.539l-.297-.023.916.9-1.197-.467.704 1.078-1.074-.832-.012.006.347 1.278-.596-1.134-.098 1.33-.401-1.326-.472 1.261.114-1.359-.015-.008-.814 1.154.286-1.067c-.34.322-.605.713-.781 1.146q.143.153.303.29.484.126 1.008.128m10.145-4.434c.971-.567 1.716-1.955 1.716-1.955s-1.893 1.955-3.205 1.665c1.186-.934 1.766-2.549 1.766-2.549s-1.506 2.325-2.448 2.423c1.086-.959 1.54-2.322 1.54-2.322s-1.237 1.817-2.196 1.944c1.287-1.161 1.338-1.893 1.338-1.893s-1.781 2.302-2.499 1.943c.858-.934 1.439-2.12 1.439-2.12s-1.489 2.019-1.893 1.69c-.277-.05.454-.958.454-.958s-.908.807-1.16.606c.454-.278 1.236-1.64 1.236-1.64S16 7.505 15.621 7.304l.731-1.483s-.73 1.483-1.715 1.23c.454-.58.63-1.112.63-1.112s-.756 1.213-1.69.885c-.22-.077.273-.635.273-.635s-.626.61-1.055.534c-.43-.076.025-.858.025-.858s-.757 1.186-.908 1.136.075-.833.075-.833-.555.908-.858.858 0-.934 0-.934-.328.984-.58.909c-.252-.076-.303-.656-.303-.656s-.068.788-.429.858c-2.725.53-5.728 1.69-9.489 5.45C3.887 10.738 5.3 7.91 11.962 7.659c5.044-.191 7.399 2.137 8.177 2.17C22.51 9.93 24 7.633 24 7.633s-1.489 1.716-2.574 1.3zm-7.74.872-.608.464v.001l.054.003a3.4 3.4 0 0 0 .554-.468m1.583-.426c0-.536-.237-.929-.594-1.217a3.2 3.2 0 0 1-.165.825.393.393 0 0 1-.328.681c-.154.233-.34.445-.549.63l.661.034-.995.237c-.025.018-.045.041-.07.058a3.2 3.2 0 0 1 1.536.691c.32-.574.504-1.235.504-1.94zM10.99 7.996a3.5 3.5 0 0 0-.785.46.43.43 0 0 1-.013.357l.885.643.023-.016-.36-1.262.627 1.12c.018-.006.04-.006.058-.011l-.02-1.251.398 1.163.477-1.15.016 1.268.012.007.713-1.005-.363 1.218.009.01 1.04-.69-.759 1.05.002.005.95-.34.041-.045a.395.395 0 0 1 .394-.632 3.4 3.4 0 0 0 .27-.784 14 14 0 0 0-2.798-.168c-.286.011-.55.033-.817.053"/></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
1
frontend/src/assets/Logos/fluxcd.svg
Normal file
1
frontend/src/assets/Logos/fluxcd.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="48 -2.25 262.5 364"><defs><style>.cls-1{fill:#326ce5}.cls-3{fill:none}</style></defs><path d="M59.724 97.778a10.183 10.183 0 0 1 0-17.073l114.07-74.16a10.18 10.18 0 0 1 11.1 0l114.07 74.16a10.183 10.183 0 0 1 0 17.073l-114.07 74.16a10.18 10.18 0 0 1-11.1 0z" class="cls-1"/><path fill="#c1d2f7" d="M197.356 110.866h7.912a6.003 6.003 0 0 0 5.2-9.005l-25.924-44.902a6.004 6.004 0 0 0-10.399 0l-25.924 44.902a6.003 6.003 0 0 0 5.2 9.005h7.912a6.003 6.003 0 0 1 6.004 6.003v51.257l5.31 3.452a12.29 12.29 0 0 0 13.395 0l5.31-3.452v-51.257a6.003 6.003 0 0 1 6.004-6.003"/><path d="M173.793 353.271a10.1 10.1 0 0 0 3.457 1.402 119 119 0 0 0-6.623-3.46zm-6.456-161.195-11.316 7.356a111 111 0 0 0 11.316 6.6zm24.015 23.716c9.739 3.115 19.813 5.648 30.11 8.186 10.92 2.692 21.973 5.427 32.818 9.009l-35.416-23.025a216 216 0 0 1-27.512-9.41zm0 62.191v.586c0 2.57-2.688 4.652-6.003 4.652H173.34c-3.315 0-6.003-2.083-6.003-4.652v-6.605c-24.906-6.402-49.873-14.485-70.86-33.82l-14.529 9.445c22.468 22.05 50.28 28.933 79.715 36.189 27.178 6.698 55.18 13.651 78.685 33.442l14.68-9.544c-18.532-16.504-40.41-23.687-63.676-29.693M93.315 300.95l38.908 25.295c20.064 5.113 40.2 11.248 58.31 23.36l14.901-9.687c-19.752-13.672-42.55-19.313-66.419-25.197-15.287-3.767-30.835-7.616-45.7-13.771m74.022-82.254a124.3 124.3 0 0 1-21.534-12.62l-14.805 9.625a124.5 124.5 0 0 0 36.339 18.708zm51.41 16.294c-9.106-2.245-18.304-4.524-27.395-7.299v13.682q5.051 1.286 10.177 2.538c29.934 7.379 60.888 15.01 85.708 39.829.61.61 1.162 1.24 1.752 1.857l9.974-6.484a10.1 10.1 0 0 0 3.475-3.8 101 101 0 0 0-3.289-3.486c-22.585-22.585-50.669-29.508-80.402-36.837m-19.933 19.933q-3.722-.918-7.462-1.855v13.21c25.817 6.561 51.803 14.688 73.455 35.04l14.521-9.44-.11-.118c-22.586-22.585-50.67-29.509-80.404-36.837m-31.477-8.587c-16.28-5.278-32.118-12.397-46.344-24.131l-14.682 9.545c17.87 15.557 38.784 22.673 61.026 28.5z" class="cls-3"/><path d="M73.24 254.96c-.348-.348-.658-.71-1-1.06l-12.514 8.136a10 10 0 0 0-1.644 1.397c1.053 1.157 2.115 2.31 3.245 3.44 22.585 22.585 50.67 29.509 80.403 36.837 25.343 6.247 51.401 12.717 73.865 29.603l14.806-9.626c-20.865-16.42-45.53-22.509-71.453-28.898-29.934-7.379-60.888-15.01-85.708-39.83" class="cls-3"/><path d="m218.864 209.962-27.512-17.886v8.476a216 216 0 0 0 27.512 9.41m-27.512 5.83v11.9c9.091 2.774 18.289 5.053 27.395 7.298 29.733 7.329 57.817 14.252 80.402 36.837a101 101 0 0 1 3.29 3.486 10.193 10.193 0 0 0-3.476-13.277l-44.683-29.05c-10.845-3.58-21.898-6.316-32.818-9.008-10.297-2.538-20.371-5.071-30.11-8.186m-24.015-9.76a111 111 0 0 1-11.316-6.6l-10.218 6.644a124.3 124.3 0 0 0 21.534 12.62zm34.192 37.88q-5.125-1.264-10.177-2.539v11.695q3.737.938 7.462 1.855c29.733 7.328 57.818 14.252 80.403 36.837.039.039.073.079.111.118l9.66-6.28c-.589-.618-1.142-1.248-1.751-1.858-24.82-24.82-55.774-32.45-85.708-39.829m-34.192-9.503a124.5 124.5 0 0 1-36.34-18.708l-10.004 6.504c14.226 11.734 30.064 18.853 46.344 24.13zm0 25.842c-22.242-5.828-43.156-12.944-61.026-28.5l-9.834 6.392c20.987 19.336 45.954 27.42 70.86 33.821zm24.015 17.732c23.266 6.006 45.144 13.19 63.675 29.693l9.78-6.358c-21.652-20.352-47.638-28.479-73.455-35.04zM81.948 247.59l-9.707 6.311c.341.35.652.712 1 1.06 24.82 24.82 55.773 32.45 85.707 39.829 25.922 6.39 50.588 12.478 71.452 28.898l9.948-6.467c-23.505-19.791-51.507-26.744-78.685-33.442-29.436-7.256-57.247-14.138-79.715-36.189m-20.621 19.284c-1.13-1.13-2.192-2.283-3.245-3.44a10.154 10.154 0 0 0 1.644 15.68l33.589 21.837c14.865 6.155 30.413 10.004 45.7 13.771 23.869 5.884 46.667 11.525 66.419 25.197l10.16-6.605c-22.463-16.886-48.521-23.356-73.864-29.603-29.733-7.328-57.818-14.252-80.403-36.837m109.3 84.34a119 119 0 0 1 6.623 3.46 10.16 10.16 0 0 0 7.645-1.402l5.639-3.666c-18.11-12.112-38.247-18.247-58.311-23.36z" class="cls-1"/></svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
1
frontend/src/assets/Logos/planetscale.svg
Normal file
1
frontend/src/assets/Logos/planetscale.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><title>PlanetScale</title><path d="M0 12C0 5.373 5.373 0 12 0c4.873 0 9.067 2.904 10.947 7.077l-15.87 15.87a12 12 0 0 1-1.935-1.099L14.99 12H12l-8.485 8.485A11.96 11.96 0 0 1 0 12m12.004 12L24 12.004C23.998 18.628 18.628 23.998 12.004 24"/></svg>
|
||||
|
After Width: | Height: | Size: 326 B |
@@ -3,7 +3,7 @@ import { Button, Popover, Tooltip } from 'antd';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { useExportRawData } from 'hooks/useExportData/useServerExport';
|
||||
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
|
||||
import { Download, LoaderCircle } from '@signozhq/icons';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface CheckboxValueRowProps {
|
||||
value: string;
|
||||
@@ -46,9 +47,11 @@ function CheckboxValueRow({
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
<TooltipSimple title={value} side="top" align="center" arrow>
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{onlyButtonLabel}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { TabsProps } from 'antd';
|
||||
import { History } from 'history';
|
||||
|
||||
export type TabRoutes = {
|
||||
name: React.ReactNode;
|
||||
route: string;
|
||||
Component: () => JSX.Element;
|
||||
Component: ComponentType;
|
||||
key: string;
|
||||
};
|
||||
|
||||
|
||||
34
frontend/src/components/TagBadge/TagBadge.tsx
Normal file
34
frontend/src/components/TagBadge/TagBadge.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { type MouseEvent, type ReactNode } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
interface TagBadgeProps {
|
||||
children: ReactNode;
|
||||
// Show a remove button (editable contexts: create modal, settings drawer).
|
||||
closable?: boolean;
|
||||
onClose?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// The single sienna tag chip used everywhere dashboards render tags — list rows,
|
||||
// the details header, and the tag editors. Kept as one component so the tag
|
||||
// styling stays identical across all of them.
|
||||
function TagBadge({
|
||||
children,
|
||||
closable,
|
||||
onClose,
|
||||
className,
|
||||
}: TagBadgeProps): JSX.Element {
|
||||
return (
|
||||
<Badge
|
||||
color="sienna"
|
||||
variant="outline"
|
||||
className={className}
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagBadge;
|
||||
@@ -15,59 +15,28 @@
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// Sienna chip — matches the dashboard list-row tag badge.
|
||||
// Sienna chip via the @signozhq Badge; this only constrains its width.
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 240px;
|
||||
height: 24px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
|
||||
color: var(--bg-sienna-400);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
// The tag label is a bare, chrome-less button inside the Badge (double-click to
|
||||
// edit); strip its Button styling and let it ellipsize.
|
||||
.tagLabel {
|
||||
--button-height: auto;
|
||||
--button-padding: 0;
|
||||
--button-gap: 0;
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
--button-variant-ghost-color: inherit;
|
||||
--button-variant-ghost-hover-color: inherit;
|
||||
--button-variant-ghost-color: currentColor;
|
||||
--button-variant-ghost-hover-color: currentColor;
|
||||
overflow: hidden;
|
||||
max-width: 200px;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.remove {
|
||||
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
|
||||
// default ghost hover is a grey that clashes with the chip. Resting color is
|
||||
// left at the Button default.
|
||||
--button-height: 16px;
|
||||
--button-padding: 0;
|
||||
--button-border-radius: 50%;
|
||||
--button-variant-ghost-hover-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--bg-sienna-500) 22%,
|
||||
transparent
|
||||
);
|
||||
--button-variant-ghost-hover-color: var(--bg-sienna-400);
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import TagBadge from '../TagBadge/TagBadge';
|
||||
import { parseKeyValueTag } from './utils';
|
||||
|
||||
import styles from './TagKeyValueInput.module.scss';
|
||||
@@ -120,27 +120,23 @@ function TagKeyValueInput({
|
||||
onBlur={commitEdit}
|
||||
/>
|
||||
) : (
|
||||
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
|
||||
<TagBadge
|
||||
key={tag}
|
||||
className={styles.tag}
|
||||
closable
|
||||
onClose={(): void => removeTag(tag)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.tagLabel}
|
||||
title="Double-click to edit"
|
||||
testId={`${testId}-chip`}
|
||||
onDoubleClick={(): void => startEdit(index)}
|
||||
>
|
||||
{tag}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.remove}
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={(): void => removeTag(tag)}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</TagBadge>
|
||||
),
|
||||
)}
|
||||
<Input
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { IS_DEV } from 'lib/env';
|
||||
import history from 'lib/history';
|
||||
import { ROLES as UserRole } from 'types/roles';
|
||||
|
||||
@@ -30,6 +31,33 @@ import { useCmdK } from '../../providers/cmdKProvider';
|
||||
|
||||
import './cmdKPalette.scss';
|
||||
|
||||
const AuthZDevModal = IS_DEV
|
||||
? React.lazy(() =>
|
||||
import('lib/authz/devtools/AuthZDevModal/AuthZDevModal').then((m) => ({
|
||||
default: m.AuthZDevModal,
|
||||
})),
|
||||
)
|
||||
: null;
|
||||
|
||||
const AuthZDevFloatingIndicator = IS_DEV
|
||||
? React.lazy(() =>
|
||||
import('lib/authz/devtools/AuthZDevFloatingIndicator/AuthZDevFloatingIndicator').then(
|
||||
(m) => ({
|
||||
default: m.AuthZDevFloatingIndicator,
|
||||
}),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
const openAuthZDevModal = IS_DEV
|
||||
? (): void => {
|
||||
void import('lib/authz/devtools/useAuthZDevStore').then((m) => {
|
||||
m.openAuthZDevModal();
|
||||
return m;
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
type CmdAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -110,6 +138,7 @@ export function CmdKPalette({
|
||||
aiAssistant: isAIAssistantEnabled
|
||||
? { open: handleOpenAIAssistant }
|
||||
: undefined,
|
||||
authzDevTools: openAuthZDevModal ? { open: openAuthZDevModal } : undefined,
|
||||
});
|
||||
|
||||
// RBAC filter: show action if no roles set OR current user role is included
|
||||
@@ -146,37 +175,57 @@ export function CmdKPalette({
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
|
||||
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
|
||||
<CommandList className="cmdk-list-scroll">
|
||||
<CommandEmpty>No results</CommandEmpty>
|
||||
{grouped.map(([section, items]) => (
|
||||
<CommandGroup
|
||||
key={section}
|
||||
heading={section}
|
||||
className="cmdk-section-heading"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<CommandItem
|
||||
key={it.id}
|
||||
onSelect={(): void => handleInvoke(it)}
|
||||
value={it.name}
|
||||
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
|
||||
>
|
||||
<span
|
||||
className={cx('cmd-item-icon', it.id === 'ai-assistant' && 'noz-icon')}
|
||||
<>
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
position="top"
|
||||
offset={110}
|
||||
>
|
||||
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
|
||||
<CommandList className="cmdk-list-scroll">
|
||||
<CommandEmpty>No results</CommandEmpty>
|
||||
{grouped.map(([section, items]) => (
|
||||
<CommandGroup
|
||||
key={section}
|
||||
heading={section}
|
||||
className="cmdk-section-heading"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<CommandItem
|
||||
key={it.id}
|
||||
onSelect={(): void => handleInvoke(it)}
|
||||
value={it.name}
|
||||
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
|
||||
>
|
||||
{it.icon}
|
||||
</span>
|
||||
{it.name}
|
||||
{it.shortcut && it.shortcut.length > 0 && (
|
||||
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
<span
|
||||
className={cx(
|
||||
'cmd-item-icon',
|
||||
it.id === 'ai-assistant' && 'noz-icon',
|
||||
)}
|
||||
>
|
||||
{it.icon}
|
||||
</span>
|
||||
{it.name}
|
||||
{it.shortcut && it.shortcut.length > 0 && (
|
||||
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
{IS_DEV && AuthZDevModal && (
|
||||
<React.Suspense fallback={null}>
|
||||
<AuthZDevModal />
|
||||
</React.Suspense>
|
||||
)}
|
||||
{IS_DEV && AuthZDevFloatingIndicator && (
|
||||
<React.Suspense fallback={null}>
|
||||
<AuthZDevFloatingIndicator />
|
||||
</React.Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ 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',
|
||||
FUNNEL_STEPS = 'FUNNEL_STEPS',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
|
||||
@@ -43,10 +43,17 @@ type ActionDeps = {
|
||||
aiAssistant?: {
|
||||
open: () => void;
|
||||
};
|
||||
/**
|
||||
* Provided only in development mode. Opens the AuthZ DevTools modal
|
||||
* for testing permission overrides.
|
||||
*/
|
||||
authzDevTools?: {
|
||||
open: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
const { navigate, handleThemeChange, aiAssistant } = deps;
|
||||
const { navigate, handleThemeChange, aiAssistant, authzDevTools } = deps;
|
||||
|
||||
const actions: CmdAction[] = [
|
||||
{
|
||||
@@ -302,5 +309,17 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
});
|
||||
}
|
||||
|
||||
if (authzDevTools) {
|
||||
actions.push({
|
||||
id: 'authz-devtools',
|
||||
name: 'AuthZ DevTools',
|
||||
keywords: 'authz permissions rbac debug devtools override testing',
|
||||
section: 'Dev',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: authzDevTools.open,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -177,7 +177,13 @@ export default function ConversationView({
|
||||
conversationId={conversationId}
|
||||
messages={messages}
|
||||
isStreaming={isStreamingHere}
|
||||
onSendSuggestedPrompt={(text): void => handleSend(text)}
|
||||
onSendSuggestedPrompt={(text): void => {
|
||||
handleSend(
|
||||
text,
|
||||
undefined,
|
||||
autoContexts.length > 0 ? autoContexts : undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{showDisclaimer && (
|
||||
<div className={disclaimerClass} role="note" aria-live="polite">
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { MessageContext } from 'api/ai-assistant/chat';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { VariantContext } from 'container/AIAssistant/VariantContext';
|
||||
|
||||
const CHIP_ID = 'recent-errors';
|
||||
const CHIP_TEXT = 'Show me recent errors';
|
||||
|
||||
// Auto-derived page context that a normal (typed) send would attach. The chip
|
||||
// send must forward the exact same array.
|
||||
const mockAutoContexts: MessageContext[] = [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: 'dashboard-123',
|
||||
resourceName: 'Checkout dashboard',
|
||||
},
|
||||
];
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/getAutoContexts', () => ({
|
||||
getAutoContexts: jest.fn(() => mockAutoContexts),
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
normalizePage: (page: string): string => page,
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({ threadId: 'thread-1' }),
|
||||
}));
|
||||
|
||||
// ChatInput is heavy and irrelevant here — the chip path lives entirely in the
|
||||
// empty state. Provide a lightweight stub plus the `autoContextKey` named export
|
||||
// ConversationView imports for its dismissed-context filter.
|
||||
jest.mock('container/AIAssistant/components/ChatInput', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="chat-input" />,
|
||||
autoContextKey: (): string => '',
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/components/ConversationSkeleton', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="skeleton" />,
|
||||
}));
|
||||
|
||||
// VirtualizedMessages renders the real empty-state chips; stub only its
|
||||
// never-rendered-in-empty-state children and the virtual list.
|
||||
jest.mock('components/Noz/Noz', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="noz" />,
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/components/MessageBubble', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/components/StreamingMessage', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
__esModule: true,
|
||||
Virtuoso: (): null => null,
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/AIAssistant/components/VirtualizedMessages/useEmptyStateChips',
|
||||
() => ({
|
||||
useEmptyStateChips: (): { chips: { id: string; text: string }[] } => ({
|
||||
chips: [{ id: CHIP_ID, text: CHIP_TEXT }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import ConversationView from '../ConversationView';
|
||||
|
||||
const CONVERSATION_ID = 'conv-1';
|
||||
|
||||
function renderView(variant: 'panel' | 'page' | 'modal'): {
|
||||
getByTestId: (id: string) => HTMLElement;
|
||||
} {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/dashboard/dashboard-123']}>
|
||||
<VariantContext.Provider value={variant}>
|
||||
<ConversationView conversationId={CONVERSATION_ID} />
|
||||
</VariantContext.Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ConversationView — empty-state chip context', () => {
|
||||
let sendMessage: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
sendMessage = jest.fn();
|
||||
useAIAssistantStore.setState({
|
||||
conversations: {
|
||||
[CONVERSATION_ID]: {
|
||||
id: CONVERSATION_ID,
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
streams: {},
|
||||
activeConversationId: CONVERSATION_ID,
|
||||
isLoadingThread: false,
|
||||
sendMessage,
|
||||
} as unknown as Partial<ReturnType<typeof useAIAssistantStore.getState>>);
|
||||
});
|
||||
|
||||
it('forwards the page auto-contexts when a chip is clicked (embedded variant)', () => {
|
||||
const { getByTestId } = renderView('panel');
|
||||
|
||||
fireEvent.click(getByTestId(`empty-state-chip-${CHIP_ID}`));
|
||||
|
||||
// The chip send must carry the same auto-contexts a typed message would.
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
CHIP_TEXT,
|
||||
undefined,
|
||||
mockAutoContexts,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends undefined contexts on the standalone page (no page context to attach)', () => {
|
||||
const { getByTestId } = renderView('page');
|
||||
|
||||
fireEvent.click(getByTestId(`empty-state-chip-${CHIP_ID}`));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(CHIP_TEXT, undefined, undefined);
|
||||
});
|
||||
});
|
||||
@@ -149,6 +149,18 @@
|
||||
line-height: 22px; /* 157.143% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 20px 16px 0px 16px;
|
||||
/* Preserve author-entered line breaks in the description. */
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-variables {
|
||||
|
||||
@@ -43,6 +43,7 @@ import { sortLayout } from 'providers/Dashboard/util';
|
||||
import { DashboardData } from 'types/api/dashboard/getAll';
|
||||
import { Props } from 'types/api/dashboard/update';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { linkifyText } from 'utils/linkifyText';
|
||||
import { ComponentTypes } from 'utils/permission';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -515,7 +516,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(description) && (
|
||||
<section className="dashboard-description-section">{description}</section>
|
||||
<section className="dashboard-description-section">
|
||||
{linkifyText(description ?? '')}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isEmpty(dashboardVariables) && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
@@ -27,7 +28,7 @@ interface PieArcProps {
|
||||
fill: string;
|
||||
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
|
||||
onLeave: () => void;
|
||||
onClick?: (slice: PieSlice) => void;
|
||||
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +73,7 @@ export default function PieArc({
|
||||
<g
|
||||
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={(): void => onClick?.(slice)}
|
||||
onClick={(event): void => onClick?.(slice, event)}
|
||||
>
|
||||
<path d={arcPath} fill={fill} />
|
||||
{shouldShowLabel && (
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('PieArc', () => {
|
||||
expect(onLeave).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(g);
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE);
|
||||
// onClick now also receives the DOM event (for drill-down popover positioning).
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
@@ -79,6 +80,10 @@ export interface PieSlice {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
/** Source query of the slice's value column — the drill-down target (present for V2 panels). */
|
||||
queryName?: string;
|
||||
/** Group-by key→value of the slice's source row, used to build drill-down filters. */
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +104,7 @@ export interface PieChartProps {
|
||||
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
|
||||
*/
|
||||
id?: string;
|
||||
/** Fired when a slice (or its legend entry) is clicked. */
|
||||
onSliceClick?: (slice: PieSlice) => void;
|
||||
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
|
||||
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
flex-direction: column;
|
||||
|
||||
// Stacked children (the FullView / standalone graph-manager) sit below the chart
|
||||
|
||||
@@ -66,20 +66,6 @@
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0 !important;
|
||||
padding-block: 0 !important;
|
||||
|
||||
:global(.quick-filters .checkbox-filter) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -111,22 +97,6 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabelIcon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.listContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -160,3 +130,106 @@
|
||||
.listContainerFiltersVisible {
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
&[data-type='filter'] {
|
||||
padding: 0 var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sectionLine {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.categoryCard {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--spacing-3);
|
||||
padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
.categoryList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.categoryItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border: none;
|
||||
border-radius: var(--spacing-2);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
--typography-color: var(--l1-foreground);
|
||||
|
||||
&:hover:not(.categoryItemSelected) {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&.categoryItemSelected {
|
||||
background: var(--accent-primary);
|
||||
color: var(--l1-background);
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-primary-hover, var(--accent-primary));
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, CollapseProps } from 'antd';
|
||||
import { Collapse, Tooltip } from 'antd';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
@@ -113,192 +112,74 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const renderCategoryLabel = (
|
||||
icon: JSX.Element,
|
||||
label: string,
|
||||
): JSX.Element => (
|
||||
<div className={styles.quickFiltersCategoryLabel}>
|
||||
<div className={styles.quickFiltersCategoryLabelContainer}>
|
||||
{icon}
|
||||
<Typography.Text>{label}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
const categories = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: K8sCategories.PODS,
|
||||
label: 'Pods',
|
||||
icon: <Container size={14} />,
|
||||
config: GetPodsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NODES,
|
||||
label: 'Nodes',
|
||||
icon: <Workflow size={14} />,
|
||||
config: GetNodesQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NAMESPACES,
|
||||
label: 'Namespaces',
|
||||
icon: <FilePenLine size={14} />,
|
||||
config: GetNamespaceQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.CLUSTERS,
|
||||
label: 'Clusters',
|
||||
icon: <Boxes size={14} />,
|
||||
config: GetClustersQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
label: 'Deployments',
|
||||
icon: <Computer size={14} />,
|
||||
config: GetDeploymentsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.JOBS,
|
||||
label: 'Jobs',
|
||||
icon: <Bolt size={14} />,
|
||||
config: GetJobsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
label: 'DaemonSets',
|
||||
icon: <Group size={14} />,
|
||||
config: GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
label: 'StatefulSets',
|
||||
icon: <ArrowUpDown size={14} />,
|
||||
config: GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.VOLUMES,
|
||||
label: 'Volumes',
|
||||
icon: <HardDrive size={14} />,
|
||||
config: GetVolumesQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
],
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const items: CollapseProps['items'] = [
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Container size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Pods',
|
||||
),
|
||||
key: K8sCategories.PODS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetPodsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Workflow size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Nodes',
|
||||
),
|
||||
key: K8sCategories.NODES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetNodesQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<FilePenLine size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Namespaces',
|
||||
),
|
||||
key: K8sCategories.NAMESPACES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetNamespaceQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Boxes size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Clusters',
|
||||
),
|
||||
key: K8sCategories.CLUSTERS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetClustersQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Computer size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Deployments',
|
||||
),
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetDeploymentsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Bolt size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Jobs',
|
||||
),
|
||||
key: K8sCategories.JOBS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetJobsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Group size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'DaemonSets',
|
||||
),
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<ArrowUpDown size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'StatefulSets',
|
||||
),
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<HardDrive size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Volumes',
|
||||
),
|
||||
key: K8sCategories.VOLUMES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetVolumesQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
// TODO: Enable once we have implemented containers.
|
||||
// {
|
||||
// label: (
|
||||
// <div className="k8s-quick-filters-category-label">
|
||||
// <div className="k8s-quick-filters-category-label-container">
|
||||
// <PackageOpen
|
||||
// size={14}
|
||||
// className="k8s-quick-filters-category-label-icon"
|
||||
// />
|
||||
// <Typography.Text>Containers</Typography.Text>
|
||||
// </div>
|
||||
// </div>
|
||||
// ),
|
||||
// key: K8sCategories.CONTAINERS,
|
||||
// showArrow: false,
|
||||
// children: (
|
||||
// <QuickFilters
|
||||
// source={QuickFiltersSource.INFRA_MONITORING}
|
||||
// config={ContainersQuickFiltersConfig}
|
||||
// handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
// onFilterChange={handleFilterChange}
|
||||
// />
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
const selectedCategoryConfig = useMemo(
|
||||
() => categories.find((cat) => cat.key === selectedCategory)?.config,
|
||||
[categories, selectedCategory],
|
||||
);
|
||||
|
||||
const handleCategoryChange = (key: string | string[]): void => {
|
||||
if (Array.isArray(key) && key.length > 0) {
|
||||
setSelectedCategory(key[0] as string);
|
||||
const handleCategorySelect = (key: string): void => {
|
||||
if (key !== selectedCategory) {
|
||||
setSelectedCategory(key);
|
||||
// Reset filters
|
||||
setUrlFilters(null);
|
||||
setOrderBy(null);
|
||||
@@ -332,26 +213,58 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
|
||||
<Tooltip title="Collapse Filters">
|
||||
<ArrowUpToLine
|
||||
style={{ transform: 'rotate(270deg)' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
Viewing · Resource
|
||||
</Typography.Text>
|
||||
<div className={styles.sectionLine} />
|
||||
<Tooltip title="Collapse Filters">
|
||||
<ArrowUpToLine
|
||||
style={{ transform: 'rotate(270deg)' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.categoryCard}>
|
||||
<div className={styles.categoryList}>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.key}
|
||||
type="button"
|
||||
className={`${styles.categoryItem} ${
|
||||
selectedCategory === category.key
|
||||
? styles.categoryItemSelected
|
||||
: ''
|
||||
}`}
|
||||
onClick={(): void => handleCategorySelect(category.key)}
|
||||
data-testid={`category-${category.key}`}
|
||||
>
|
||||
{category.icon}
|
||||
<Typography.Text>{category.label}</Typography.Text>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.quickFiltersSection}>
|
||||
<div className={styles.sectionHeader} data-type="filter">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
Filter by
|
||||
</Typography.Text>
|
||||
<div className={styles.sectionLine} />
|
||||
</div>
|
||||
{selectedCategoryConfig && (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={selectedCategoryConfig}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Collapse
|
||||
onChange={handleCategoryChange}
|
||||
items={items}
|
||||
defaultActiveKey={[selectedCategory]}
|
||||
activeKey={[selectedCategory]}
|
||||
accordion
|
||||
bordered={false}
|
||||
ghost
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1625,6 +1625,9 @@ export const getHostQueryPayload = (
|
||||
const diskPendingKey = dotMetricsEnabled
|
||||
? 'system.disk.pending_operations'
|
||||
: 'system_disk_pending_operations';
|
||||
const fsUsageKey = dotMetricsEnabled
|
||||
? 'system.filesystem.usage'
|
||||
: 'system_filesystem_usage';
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -2657,6 +2660,155 @@ export const getHostQueryPayload = (
|
||||
start,
|
||||
end,
|
||||
},
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TIME_SERIES,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'system_filesystem_usage--float64--Gauge--true',
|
||||
|
||||
key: fsUsageKey,
|
||||
type: 'Gauge',
|
||||
},
|
||||
aggregateOperator: 'avg',
|
||||
dataSource: DataSource.METRICS,
|
||||
disabled: true,
|
||||
expression: 'A',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: 'fs_f1',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'host_name--string--tag--false',
|
||||
|
||||
key: hostNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: hostName,
|
||||
},
|
||||
{
|
||||
id: 'fs_f2',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'state--string--tag--false',
|
||||
|
||||
key: 'state',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: 'used',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'mountpoint--string--tag--false',
|
||||
|
||||
key: 'mountpoint',
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
having: [
|
||||
{
|
||||
columnName: `SUM(${fsUsageKey})`,
|
||||
op: '>',
|
||||
value: 0,
|
||||
},
|
||||
],
|
||||
legend: '{{mountpoint}}',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'system_filesystem_usage--float64--Gauge--true',
|
||||
|
||||
key: fsUsageKey,
|
||||
type: 'Gauge',
|
||||
},
|
||||
aggregateOperator: 'avg',
|
||||
dataSource: DataSource.METRICS,
|
||||
disabled: true,
|
||||
expression: 'B',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: 'fs_f3',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'host_name--string--tag--false',
|
||||
|
||||
key: hostNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: hostName,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'mountpoint--string--tag--false',
|
||||
|
||||
key: 'mountpoint',
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
having: [
|
||||
{
|
||||
columnName: `SUM(${fsUsageKey})`,
|
||||
op: '>',
|
||||
value: 0,
|
||||
},
|
||||
],
|
||||
legend: '{{mountpoint}}',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'B',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
],
|
||||
queryFormulas: [
|
||||
{
|
||||
disabled: false,
|
||||
expression: 'A/B',
|
||||
legend: '{{mountpoint}}',
|
||||
queryName: 'F1',
|
||||
},
|
||||
],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: false,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -2732,4 +2884,5 @@ export const hostWidgetInfo = [
|
||||
{ title: 'System disk operations/s', yAxisUnit: 'short' },
|
||||
{ title: 'Queue size', yAxisUnit: 'short' },
|
||||
{ title: 'System disk operation time/s', yAxisUnit: 's' },
|
||||
{ title: 'Disk Usage (%) by mountpoint', yAxisUnit: 'percentunit' },
|
||||
];
|
||||
|
||||
@@ -20,6 +20,7 @@ import azureOpenaiUrl from '@/assets/Logos/azure-openai.svg';
|
||||
import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metrics.svg';
|
||||
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
|
||||
import basetenUrl from '@/assets/Logos/baseten.svg';
|
||||
import cassandraUrl from '@/assets/Logos/cassandra.svg';
|
||||
import celeryUrl from '@/assets/Logos/celery.svg';
|
||||
import certManagerUrl from '@/assets/Logos/cert-manager.svg';
|
||||
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
|
||||
@@ -51,6 +52,7 @@ import externalApiMonitoringUrl from '@/assets/Logos/external-api-monitoring.svg
|
||||
import fluentbitUrl from '@/assets/Logos/fluentbit.svg';
|
||||
import fluentdUrl from '@/assets/Logos/fluentd.svg';
|
||||
import flutterMonitoringUrl from '@/assets/Logos/flutter-monitoring.svg';
|
||||
import fluxcdUrl from '@/assets/Logos/fluxcd.svg';
|
||||
import flyIoUrl from '@/assets/Logos/fly-io.svg';
|
||||
import fromLogFileUrl from '@/assets/Logos/from-log-file.svg';
|
||||
import gcpAppEngineUrl from '@/assets/Logos/gcp-app-engine.svg';
|
||||
@@ -94,6 +96,7 @@ import langtraceUrl from '@/assets/Logos/langtrace.svg';
|
||||
import litellmUrl from '@/assets/Logos/litellm.svg';
|
||||
import livekitUrl from '@/assets/Logos/livekit.svg';
|
||||
import llamaindexUrl from '@/assets/Logos/llamaindex.svg';
|
||||
import llmMonitoringUrl from '@/assets/Logos/llm-monitoring.svg';
|
||||
import logrusUrl from '@/assets/Logos/logrus.svg';
|
||||
import logsUrl from '@/assets/Logos/logs.svg';
|
||||
import logstashUrl from '@/assets/Logos/logstash.svg';
|
||||
@@ -120,6 +123,7 @@ import opentelemetryUrl from '@/assets/Logos/opentelemetry.svg';
|
||||
import phpUrl from '@/assets/Logos/php.svg';
|
||||
import pinoUrl from '@/assets/Logos/pino.svg';
|
||||
import pipecatUrl from '@/assets/Logos/pipecat.svg';
|
||||
import planetscaleUrl from '@/assets/Logos/planetscale.svg';
|
||||
import postgresqlUrl from '@/assets/Logos/postgresql.svg';
|
||||
import prometheusUrl from '@/assets/Logos/prometheus.svg';
|
||||
import pydanticAiUrl from '@/assets/Logos/pydantic-ai.svg';
|
||||
@@ -1526,6 +1530,28 @@ const onboardingConfigWithLinks = [
|
||||
id: 'nginx-tracing',
|
||||
link: '/docs/instrumentation/opentelemetry-nginx/',
|
||||
},
|
||||
{
|
||||
dataSource: 'nginx-ingress-controller',
|
||||
label: 'NGINX Ingress Controller',
|
||||
imgUrl: nginxUrl,
|
||||
tags: ['infrastructure monitoring'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'ingress',
|
||||
'ingress controller',
|
||||
'kubernetes ingress',
|
||||
'monitoring',
|
||||
'nginx ingress',
|
||||
'nginx ingress controller',
|
||||
'nginx ingress metrics',
|
||||
'nginx ingress monitoring',
|
||||
'nginx ingress observability',
|
||||
'observability',
|
||||
'opentelemetry nginx ingress',
|
||||
],
|
||||
id: 'nginx-ingress-controller',
|
||||
link: '/docs/metrics-management/nginx-ingress-controller/',
|
||||
},
|
||||
{
|
||||
dataSource: 'opentelemetry-cloudflare',
|
||||
label: 'Cloudflare Tracing',
|
||||
@@ -1586,6 +1612,119 @@ const onboardingConfigWithLinks = [
|
||||
id: 'opentelemetry-cloudflare-logs',
|
||||
link: '/docs/logs-management/send-logs/cloudflare-logs/',
|
||||
},
|
||||
{
|
||||
dataSource: 'cloudflare-workers',
|
||||
label: 'Cloudflare Workers',
|
||||
imgUrl: cloudflareUrl,
|
||||
tags: ['apm/traces'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'cloudflare',
|
||||
'cloudflare workers',
|
||||
'cloudflare workers monitoring',
|
||||
'cloudflare workers observability',
|
||||
'cloudflare workers otlp',
|
||||
'edge computing monitoring',
|
||||
'monitor cloudflare workers',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry cloudflare workers',
|
||||
'otlp',
|
||||
'serverless monitoring',
|
||||
],
|
||||
id: 'cloudflare-workers',
|
||||
link: '/docs/integrations/outposts/cloudflare-workers/',
|
||||
},
|
||||
{
|
||||
dataSource: 'opentelemetry-cassandra',
|
||||
label: 'Cassandra',
|
||||
imgUrl: cassandraUrl,
|
||||
tags: ['database'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'apache cassandra',
|
||||
'cassandra',
|
||||
'cassandra database',
|
||||
'cassandra logs',
|
||||
'cassandra metrics',
|
||||
'cassandra monitoring',
|
||||
'cassandra observability',
|
||||
'database',
|
||||
'monitoring',
|
||||
'nosql',
|
||||
'observability',
|
||||
'opentelemetry cassandra',
|
||||
],
|
||||
id: 'opentelemetry-cassandra',
|
||||
link: '/docs/integrations/opentelemetry-cassandra/',
|
||||
},
|
||||
{
|
||||
dataSource: 'fluxcd',
|
||||
label: 'FluxCD',
|
||||
imgUrl: fluxcdUrl,
|
||||
tags: ['infrastructure monitoring'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'continuous delivery',
|
||||
'flux',
|
||||
'fluxcd',
|
||||
'fluxcd dashboard',
|
||||
'fluxcd metrics',
|
||||
'fluxcd monitoring',
|
||||
'fluxcd observability',
|
||||
'gitops',
|
||||
'kubernetes',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry fluxcd',
|
||||
],
|
||||
id: 'fluxcd',
|
||||
link: '/docs/metrics-management/fluxcd-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'planetscale',
|
||||
label: 'PlanetScale',
|
||||
imgUrl: planetscaleUrl,
|
||||
tags: ['database'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'monitoring',
|
||||
'mysql',
|
||||
'observability',
|
||||
'opentelemetry planetscale',
|
||||
'planetscale',
|
||||
'planetscale database',
|
||||
'planetscale metrics',
|
||||
'planetscale monitoring',
|
||||
'planetscale observability',
|
||||
'serverless database',
|
||||
],
|
||||
id: 'planetscale',
|
||||
link: '/docs/metrics-management/opentelemetry-planetscale/',
|
||||
},
|
||||
{
|
||||
dataSource: 'hermes-agent',
|
||||
label: 'Hermes Agent',
|
||||
imgUrl: llmMonitoringUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'ai agent monitoring',
|
||||
'hermes',
|
||||
'hermes agent',
|
||||
'hermes agent monitoring',
|
||||
'hermes agent observability',
|
||||
'hermes monitoring',
|
||||
'llm monitoring',
|
||||
'monitoring',
|
||||
'nous research',
|
||||
'observability',
|
||||
'opentelemetry hermes',
|
||||
],
|
||||
id: 'hermes-agent',
|
||||
link: '/docs/hermes-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'convex-logs',
|
||||
label: 'Convex Logs',
|
||||
|
||||
@@ -48,6 +48,8 @@ describe('CreateRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ describe('EditRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -409,6 +409,8 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetServiceAccountRolesQueryKey,
|
||||
useCreateServiceAccountRole,
|
||||
useDeleteServiceAccountRole,
|
||||
useCreateServiceAccountRoleDeprecated,
|
||||
useDeleteServiceAccountRoleDeprecated,
|
||||
useGetServiceAccountRoles,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -46,10 +46,10 @@ export function useServiceAccountRoleManager(
|
||||
);
|
||||
|
||||
// the retry for these mutations is safe due to being idempotent on backend
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRole({
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRoleDeprecated({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRoleDeprecated({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { downloadFile } from 'lib/exportData/downloadFile';
|
||||
import { ExportFormat, TimeseriesShape } from 'lib/exportData/types';
|
||||
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import { useClientExport } from '../useClientExport';
|
||||
|
||||
jest.mock('lib/exportData/downloadFile', () => ({ downloadFile: jest.fn() }));
|
||||
|
||||
const mockMessageError = jest.fn();
|
||||
jest.mock('antd', () => {
|
||||
const actual = jest.requireActual('antd');
|
||||
return {
|
||||
...actual,
|
||||
message: { error: (...args: unknown[]): void => mockMessageError(...args) },
|
||||
};
|
||||
});
|
||||
|
||||
const mockDownloadFile = downloadFile as jest.Mock;
|
||||
|
||||
function timeSeriesResponse(): QueryRangeResponseV5 {
|
||||
return {
|
||||
type: 'time_series',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: '',
|
||||
meta: {},
|
||||
series: [
|
||||
{
|
||||
labels: [{ key: { name: 'service' }, value: 'a' }],
|
||||
values: [{ timestamp: 1000, value: 12 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
}
|
||||
|
||||
describe('useClientExport', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('exports time_series as CSV to <fileName>.csv', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useClientExport({
|
||||
response: timeSeriesResponse(),
|
||||
fileName: 'chart',
|
||||
legendMap: { A: '{{service}}' },
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExport({
|
||||
format: ExportFormat.Csv,
|
||||
shape: TimeseriesShape.Long,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
|
||||
const [content, name, mime] = mockDownloadFile.mock.calls[0];
|
||||
expect(name).toBe('chart.csv');
|
||||
expect(mime).toContain('text/csv');
|
||||
expect(content).toContain('service');
|
||||
expect(content).toContain('a');
|
||||
});
|
||||
|
||||
it('exports as JSONL to <fileName>.jsonl with the ndjson mime', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useClientExport({ response: timeSeriesResponse() }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExport({ format: ExportFormat.Jsonl });
|
||||
});
|
||||
|
||||
const [content, name, mime] = mockDownloadFile.mock.calls[0];
|
||||
expect(name).toBe('export.jsonl');
|
||||
expect(mime).toContain('ndjson');
|
||||
expect(content).toContain('"series"');
|
||||
});
|
||||
|
||||
it('does nothing when there is no response', async () => {
|
||||
const { result } = renderHook(() => useClientExport({}));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExport({ format: ExportFormat.Csv });
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockMessageError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error and does not download for unsupported result types', async () => {
|
||||
const raw = {
|
||||
type: 'raw',
|
||||
data: { results: [] },
|
||||
meta: {},
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
const { result } = renderHook(() => useClientExport({ response: raw }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExport({ format: ExportFormat.Csv });
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockMessageError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { message } from 'antd';
|
||||
import { downloadFile } from 'lib/exportData/downloadFile';
|
||||
import { exportScalarData } from 'lib/exportData/exportScalarData';
|
||||
import { exportTimeseriesData } from 'lib/exportData/exportTimeseriesData';
|
||||
import { toCsv } from 'lib/exportData/toCsv';
|
||||
import { toJsonl } from 'lib/exportData/toJsonl';
|
||||
import {
|
||||
ExportFormat,
|
||||
SerializedTable,
|
||||
TimeseriesShape,
|
||||
} from 'lib/exportData/types';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
QueryRangeResponseV5,
|
||||
ScalarData,
|
||||
TimeSeriesData,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
const FORMAT_META: Record<ExportFormat, { mime: string; extension: string }> = {
|
||||
[ExportFormat.Csv]: { mime: 'text/csv;charset=utf-8;', extension: 'csv' },
|
||||
[ExportFormat.Jsonl]: {
|
||||
mime: 'application/x-ndjson;charset=utf-8;',
|
||||
extension: 'jsonl',
|
||||
},
|
||||
};
|
||||
|
||||
// Picks the serializer for the response's request type. Narrows the results
|
||||
// union via the response discriminant (raw/trace/distribution aren't client-exported).
|
||||
function serialize(
|
||||
response: QueryRangeResponseV5,
|
||||
shape: TimeseriesShape,
|
||||
yAxisUnit?: string,
|
||||
legendMap?: Record<string, string>,
|
||||
): SerializedTable {
|
||||
switch (response.type) {
|
||||
case 'time_series':
|
||||
return exportTimeseriesData({
|
||||
data: response.data.results as TimeSeriesData[],
|
||||
shape,
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
});
|
||||
case 'scalar':
|
||||
return exportScalarData({
|
||||
data: response.data.results as ScalarData[],
|
||||
yAxisUnit,
|
||||
});
|
||||
default:
|
||||
throw new Error(`Export is not supported for "${response.type}" results`);
|
||||
}
|
||||
}
|
||||
|
||||
interface UseClientExportProps {
|
||||
response?: QueryRangeResponseV5;
|
||||
yAxisUnit?: string;
|
||||
fileName?: string;
|
||||
legendMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ClientExportOptions {
|
||||
format: ExportFormat;
|
||||
shape?: TimeseriesShape;
|
||||
}
|
||||
|
||||
interface UseClientExportReturn {
|
||||
isExporting: boolean;
|
||||
handleExport: (options: ClientExportOptions) => Promise<void>;
|
||||
}
|
||||
|
||||
// Frontend-driven export: serializes in-memory query results and downloads them
|
||||
// client-side. Backend-driven export lives in useServerExport.
|
||||
export function useClientExport({
|
||||
response,
|
||||
yAxisUnit,
|
||||
fileName = 'export',
|
||||
legendMap,
|
||||
}: UseClientExportProps): UseClientExportReturn {
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
|
||||
const handleExport = useCallback(
|
||||
async ({
|
||||
format,
|
||||
shape = TimeseriesShape.Long,
|
||||
}: ClientExportOptions): Promise<void> => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const table = serialize(response, shape, yAxisUnit, legendMap);
|
||||
const content =
|
||||
format === ExportFormat.Jsonl ? toJsonl(table) : toCsv(table);
|
||||
const { mime, extension } = FORMAT_META[format];
|
||||
await downloadFile(content, `${fileName}.${extension}`, mime);
|
||||
} catch {
|
||||
message.error('Failed to export data. Please try again.');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
},
|
||||
[response, yAxisUnit, fileName, legendMap],
|
||||
);
|
||||
|
||||
return { isExporting, handleExport };
|
||||
}
|
||||
21
frontend/src/lib/authz/README.md
Normal file
21
frontend/src/lib/authz/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# AuthZ
|
||||
|
||||
Permission-based authorization system for SigNoz frontend.
|
||||
|
||||
## Supported Resources
|
||||
|
||||
See [hooks/useAuthZ/permissions.type.ts](./hooks/useAuthZ/permissions.type.ts) for available resources and verbs.
|
||||
|
||||
If your page/content represents a resource not listed there, skip authz implementation — the backend doesn't enforce it yet.
|
||||
|
||||
## UI Gating
|
||||
|
||||
Need to gate UI based on permissions? See [components/README.md](./components/README.md).
|
||||
|
||||
Covers: AuthZButton, AuthZTooltip, withAuthZ*, AuthZGuard*, when to use each.
|
||||
|
||||
## Testing
|
||||
|
||||
Need to test authz behavior? See [utils/README.md](./utils/README.md).
|
||||
|
||||
Covers: MSW handlers, mock hooks, test patterns.
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
import AuthZButton from './AuthZButton';
|
||||
|
||||
// AuthZButton is a thin composition over AuthZTooltip + Button. The denial
|
||||
// tooltip / disabled-on-deny UX is owned and tested by AuthZTooltip; here we
|
||||
// assert AuthZButton forwards the right props and renders a Button child.
|
||||
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip');
|
||||
const mockTooltip = AuthZTooltip as unknown as jest.Mock;
|
||||
|
||||
const createPerm = buildPermission(
|
||||
'create',
|
||||
'serviceaccount:*' as AuthZObject<'create'>,
|
||||
);
|
||||
|
||||
describe('AuthZButton', () => {
|
||||
beforeEach(() => {
|
||||
mockTooltip.mockImplementation(
|
||||
({ children }: { children: ReactElement }) => children,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockTooltip.mockReset();
|
||||
});
|
||||
|
||||
it('renders a Button child with forwarded props', () => {
|
||||
render(
|
||||
<AuthZButton checks={[createPerm]} testId="create-btn">
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('create-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('create-btn').tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('forwards checks and enables the check by default', () => {
|
||||
render(
|
||||
<AuthZButton checks={[createPerm]} testId="create-btn">
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(mockTooltip).toHaveBeenCalledTimes(1);
|
||||
expect(mockTooltip.mock.calls[0][0]).toMatchObject({
|
||||
checks: [createPerm],
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a custom tooltipMessage', () => {
|
||||
render(
|
||||
<AuthZButton
|
||||
checks={[createPerm]}
|
||||
tooltipMessage="Ask an admin"
|
||||
testId="create-btn"
|
||||
>
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(mockTooltip.mock.calls[0][0]).toMatchObject({
|
||||
tooltipMessage: 'Ask an admin',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes authZEnabled through as the tooltip enabled flag', () => {
|
||||
render(
|
||||
<AuthZButton checks={[createPerm]} authZEnabled={false} testId="create-btn">
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(mockTooltip.mock.calls[0][0]).toMatchObject({ enabled: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button, ButtonProps } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
export type AuthZButtonProps = ButtonProps & {
|
||||
/**
|
||||
* Permissions required to enable the button (AND semantics).
|
||||
*/
|
||||
checks: BrandedPermission[];
|
||||
/**
|
||||
* Override the default denial tooltip message.
|
||||
*/
|
||||
tooltipMessage?: string;
|
||||
/**
|
||||
* Gate the permission check itself. When false, renders a plain button.
|
||||
*/
|
||||
authZEnabled?: boolean;
|
||||
};
|
||||
|
||||
function AuthZButton({
|
||||
checks,
|
||||
tooltipMessage,
|
||||
authZEnabled = true,
|
||||
...buttonProps
|
||||
}: AuthZButtonProps): JSX.Element {
|
||||
return (
|
||||
<AuthZTooltip
|
||||
checks={checks}
|
||||
enabled={authZEnabled}
|
||||
tooltipMessage={tooltipMessage}
|
||||
>
|
||||
<Button {...buttonProps} />
|
||||
</AuthZTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthZButton;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
import { AuthZGuard } from './AuthZGuard';
|
||||
import { AuthZGuardContent } from './AuthZGuardContent';
|
||||
import { AuthZGuardPage } from './AuthZGuardPage';
|
||||
|
||||
const readPerm = buildPermission('read', 'role:*' as AuthZObject<'read'>);
|
||||
|
||||
const Protected = (): JSX.Element => <div>Protected content</div>;
|
||||
|
||||
describe('AuthZGuard', () => {
|
||||
it('renders children when allowed', async () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the fallback when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]} fallback={<div>No access</div>}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No access')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes denied permissions to a function fallback', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard
|
||||
checks={[readPerm]}
|
||||
fallback={({ deniedPermissions }): JSX.Element => (
|
||||
<div>denied: {deniedPermissions.length}</div>
|
||||
)}
|
||||
>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('denied: 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing for a denied check with no fallback', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
const { container } = render(
|
||||
<AuthZGuard checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders the loading fallback while checking', () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]} fallbackOnLoading={<div>Loading…</div>}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fails open on error by default (renders children)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'boom' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]} fallback={<div>No access</div>}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the fallback on error when failOpenOnError is false', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'boom' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZGuard
|
||||
checks={[readPerm]}
|
||||
onFailRenderContent={false}
|
||||
fallback={<div>No access</div>}
|
||||
>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No access')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthZGuardPage', () => {
|
||||
it('renders the full-page denied screen when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardPage checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardPage>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Uh-oh! You are not authorized'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('read:role:*')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the app loader while checking', () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardPage checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardPage>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'OpenTelemetry-Native Logs, Metrics and Traces in a single pane',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthZGuardContent', () => {
|
||||
it('renders the denied callout when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardContent checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardContent>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('read:role:*')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children when allowed', async () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardContent checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardContent>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
82
frontend/src/lib/authz/components/AuthZGuard/AuthZGuard.tsx
Normal file
82
frontend/src/lib/authz/components/AuthZGuard/AuthZGuard.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { ReactElement, ReactNode, useMemo } from 'react';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
export type AuthZGuardFallback =
|
||||
| ReactNode
|
||||
| ((info: { deniedPermissions: BrandedPermission[] }) => ReactNode);
|
||||
|
||||
export type AuthZGuardProps = {
|
||||
/**
|
||||
* Permissions required to render `children` (AND semantics).
|
||||
*/
|
||||
checks: BrandedPermission[];
|
||||
children: ReactElement;
|
||||
/**
|
||||
* Rendered when denied. A function receives the denied permissions.
|
||||
*/
|
||||
fallback?: AuthZGuardFallback;
|
||||
fallbackOnLoading?: ReactNode;
|
||||
/**
|
||||
* By default, we don't expect the check API request to fail, in those cases, we prefer to show the content and then let the API fail (during list/create).
|
||||
*
|
||||
* In case you want to have a different behavior when request fail, set to false.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
onFailRenderContent?: boolean;
|
||||
};
|
||||
|
||||
function resolveFallback(
|
||||
fallback: AuthZGuardFallback | undefined,
|
||||
deniedPermissions: BrandedPermission[],
|
||||
): ReactNode {
|
||||
if (typeof fallback === 'function') {
|
||||
return fallback({ deniedPermissions });
|
||||
}
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
export function AuthZGuard({
|
||||
checks,
|
||||
children,
|
||||
fallback,
|
||||
fallbackOnLoading,
|
||||
onFailRenderContent = true,
|
||||
}: AuthZGuardProps): JSX.Element | null {
|
||||
const { isLoading, error, permissions } = useAuthZ(checks);
|
||||
|
||||
// TODO(authz): Use allowed/deniedPermissions from useAuthZ after devtools PR merges
|
||||
const { allowed, deniedPermissions } = useMemo(() => {
|
||||
if (!permissions) {
|
||||
return { allowed: false, deniedPermissions: [] as BrandedPermission[] };
|
||||
}
|
||||
|
||||
const denied = Object.entries(permissions)
|
||||
.filter(([, { isGranted }]) => !isGranted)
|
||||
.map(([perm]) => perm as BrandedPermission);
|
||||
|
||||
return {
|
||||
allowed: denied.length === 0,
|
||||
deniedPermissions: denied,
|
||||
};
|
||||
}, [permissions]);
|
||||
|
||||
if (isLoading) {
|
||||
return <>{fallbackOnLoading ?? null}</>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return onFailRenderContent ? (
|
||||
children
|
||||
) : (
|
||||
<>{resolveFallback(fallback, deniedPermissions)}</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return <>{resolveFallback(fallback, deniedPermissions)}</>;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ReactElement } from 'react';
|
||||
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
|
||||
import { AuthZGuard, AuthZGuardProps } from './AuthZGuard';
|
||||
|
||||
export function AuthZGuardContent({
|
||||
fallback,
|
||||
...rest
|
||||
}: AuthZGuardProps): JSX.Element | null {
|
||||
return (
|
||||
<AuthZGuard
|
||||
{...rest}
|
||||
fallback={
|
||||
fallback ??
|
||||
(({ deniedPermissions }): ReactElement => (
|
||||
<PermissionDeniedCallout deniedPermissions={deniedPermissions} />
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ReactElement } from 'react';
|
||||
import AppLoading from 'components/AppLoading/AppLoading';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
|
||||
import { AuthZGuard, AuthZGuardProps } from './AuthZGuard';
|
||||
|
||||
export function AuthZGuardPage({
|
||||
fallback,
|
||||
fallbackOnLoading,
|
||||
...rest
|
||||
}: AuthZGuardProps): JSX.Element | null {
|
||||
return (
|
||||
<AuthZGuard
|
||||
{...rest}
|
||||
fallbackOnLoading={fallbackOnLoading ?? <AppLoading />}
|
||||
fallback={
|
||||
fallback ??
|
||||
(({ deniedPermissions }): ReactElement => (
|
||||
<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ const noPermissions = {
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [] as BrandedPermission[],
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -160,11 +162,11 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
const button = screen.getByRole('button', { name: 'Action' });
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
const wrapper = screen.getByRole('button', { name: 'Action' }).parentElement;
|
||||
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(sa);
|
||||
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(
|
||||
expect(button.getAttribute('data-denied-permissions')).toContain(sa);
|
||||
expect(button.getAttribute('data-denied-permissions')).toContain(
|
||||
attachRolePerm,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
.wrapper {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
background: var(--callout-error-background) !important;
|
||||
border-color: var(--callout-error-border) !important;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactElement, cloneElement, useMemo } from 'react';
|
||||
import { CSSProperties, ReactElement, cloneElement, useMemo } from 'react';
|
||||
import {
|
||||
TooltipRoot,
|
||||
TooltipContent,
|
||||
@@ -11,6 +11,13 @@ import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import styles from './AuthZTooltip.module.scss';
|
||||
|
||||
const DISABLED_STYLE: CSSProperties = {
|
||||
pointerEvents: 'all',
|
||||
cursor: 'not-allowed',
|
||||
};
|
||||
|
||||
const noOp = (): void => {};
|
||||
|
||||
interface AuthZTooltipProps {
|
||||
checks: BrandedPermission[];
|
||||
children: ReactElement;
|
||||
@@ -49,11 +56,13 @@ function AuthZTooltip({
|
||||
}, [checks, permissions]);
|
||||
|
||||
if (shouldCheck && isLoading) {
|
||||
return (
|
||||
<span className={styles.wrapper}>
|
||||
{cloneElement(children, { disabled: true })}
|
||||
</span>
|
||||
);
|
||||
return cloneElement(children, {
|
||||
disabled: true,
|
||||
style: DISABLED_STYLE,
|
||||
onClick: noOp,
|
||||
onMouseDown: noOp,
|
||||
onPointerDown: noOp,
|
||||
});
|
||||
}
|
||||
|
||||
if (!shouldCheck || deniedPermissions.length === 0) {
|
||||
@@ -64,12 +73,14 @@ function AuthZTooltip({
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={styles.wrapper}
|
||||
data-denied-permissions={deniedPermissions.join(',')}
|
||||
>
|
||||
{cloneElement(children, { disabled: true })}
|
||||
</span>
|
||||
{cloneElement(children, {
|
||||
disabled: true,
|
||||
style: DISABLED_STYLE,
|
||||
onClick: noOp,
|
||||
onMouseDown: noOp,
|
||||
onPointerDown: noOp,
|
||||
'data-denied-permissions': deniedPermissions.join(','),
|
||||
})}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={styles.errorContent}>
|
||||
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import { GuardAuthZ } from './GuardAuthZ';
|
||||
|
||||
describe('GuardAuthZ', () => {
|
||||
const TestChild = (): ReactElement => <div>Protected Content</div>;
|
||||
const LoadingFallback = (): ReactElement => <div>Loading...</div>;
|
||||
const NoPermissionFallback = (_response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement => <div>Access denied</div>;
|
||||
const NoPermissionFallbackWithSuggestions = (response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement => (
|
||||
<div>
|
||||
Access denied. Required permission: {response.requiredPermissionName}
|
||||
</div>
|
||||
);
|
||||
|
||||
it('should render children when permission is granted', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render fallbackOnLoading when loading', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.delay('infinite'),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object="role:*"
|
||||
fallbackOnLoading={<LoadingFallback />}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when loading and no fallbackOnLoading provided', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.delay('infinite'),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render children when API error occurs and no fallbackOnError provided (fail open)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render fallbackOnError when API error occurs and fallbackOnError is provided', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object="role:*"
|
||||
fallbackOnError={<div>Custom error fallback</div>}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Custom error fallback')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render fallbackOnNoPermissions when 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, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="update"
|
||||
object="role:123"
|
||||
fallbackOnNoPermissions={NoPermissionFallback}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Access denied')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when permission is denied and no fallbackOnNoPermissions provided', 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, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="update" object="role:123">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when permissions object is null', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json({ data: [], status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass requiredPermissionName to fallbackOnNoPermissions', async () => {
|
||||
const permission = buildPermission('update', 'role:123');
|
||||
|
||||
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, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="update"
|
||||
object="role:123"
|
||||
fallbackOnNoPermissions={NoPermissionFallbackWithSuggestions}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Access denied. Required permission:/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
new RegExp(permission.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle different relation and object combinations', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<GuardAuthZ relation="delete" object="role:456">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
export type GuardAuthZProps<R extends AuthZRelation> = {
|
||||
children: ReactElement;
|
||||
relation: R;
|
||||
object: AuthZObject<R>;
|
||||
fallbackOnLoading?: JSX.Element;
|
||||
fallbackOnError?: JSX.Element;
|
||||
fallbackOnNoPermissions?: (response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}) => JSX.Element;
|
||||
};
|
||||
|
||||
export function GuardAuthZ<R extends AuthZRelation>({
|
||||
children,
|
||||
relation,
|
||||
object,
|
||||
fallbackOnLoading,
|
||||
fallbackOnError,
|
||||
fallbackOnNoPermissions,
|
||||
}: GuardAuthZProps<R>): JSX.Element | null {
|
||||
const permission = buildPermission<R>(relation, object);
|
||||
|
||||
const { permissions, isLoading, error } = useAuthZ([permission]);
|
||||
|
||||
if (isLoading) {
|
||||
return fallbackOnLoading ?? null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return fallbackOnError ?? children;
|
||||
}
|
||||
|
||||
if (!permissions?.[permission]?.isGranted) {
|
||||
return (
|
||||
fallbackOnNoPermissions?.({
|
||||
requiredPermissionName: permission,
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,18 +1,39 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedCallout from './PermissionDeniedCallout';
|
||||
import {
|
||||
buildPermission,
|
||||
buildObjectString,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
describe('PermissionDeniedCallout', () => {
|
||||
it('renders the permission name in the callout message', () => {
|
||||
render(<PermissionDeniedCallout permissionName="serviceaccount:attach" />);
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
];
|
||||
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);
|
||||
|
||||
expect(screen.getByText(/is not authorized/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:attach/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple denied permissions', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
buildPermission('update', buildObjectString('role', 'admin')),
|
||||
];
|
||||
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);
|
||||
|
||||
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/update:role:admin/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts an optional className', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
];
|
||||
const { container } = render(
|
||||
<PermissionDeniedCallout
|
||||
permissionName="serviceaccount:read"
|
||||
deniedPermissions={deniedPermissions}
|
||||
className="custom-class"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -3,18 +3,32 @@ import cx from 'classnames';
|
||||
import styles from './PermissionDeniedCallout.module.scss';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
interface PermissionDeniedCalloutProps {
|
||||
permissionName: string;
|
||||
export interface PermissionDeniedCalloutProps {
|
||||
/**
|
||||
* @deprecated Use `deniedPermissions` instead. Will be removed after authz devtools PR merges.
|
||||
*/
|
||||
permissionName?: string;
|
||||
deniedPermissions?: BrandedPermission[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function PermissionDeniedCallout({
|
||||
permissionName,
|
||||
deniedPermissions,
|
||||
className,
|
||||
}: PermissionDeniedCalloutProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
// TODO(authz): Remove permissionName support after devtools PR merges
|
||||
const formattedPermissions = deniedPermissions
|
||||
? deniedPermissions.map(formatPermission)
|
||||
: permissionName
|
||||
? [permissionName]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Callout
|
||||
type="error"
|
||||
@@ -25,7 +39,12 @@ function PermissionDeniedCallout({
|
||||
<Typography.Text className={styles.permission}>
|
||||
<code className={styles.permissionCode}>user/{user.id}</code> is not
|
||||
authorized to perform{' '}
|
||||
<code className={styles.permissionCode}>{permissionName}</code>
|
||||
{formattedPermissions.map((perm, idx) => (
|
||||
<span key={perm}>
|
||||
<code className={styles.permissionCode}>{perm}</code>
|
||||
{idx < formattedPermissions.length - 1 && ', '}
|
||||
</span>
|
||||
))}
|
||||
</Typography.Text>
|
||||
</Callout>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedFullPage from './PermissionDeniedFullPage';
|
||||
import {
|
||||
buildPermission,
|
||||
buildObjectString,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
describe('PermissionDeniedFullPage', () => {
|
||||
it('renders the title and subtitle with the permissionName interpolated', () => {
|
||||
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
];
|
||||
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
|
||||
|
||||
expect(screen.getByText('Uh-oh! You are not authorized')).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with a different permissionName', () => {
|
||||
render(<PermissionDeniedFullPage permissionName="role:read" />);
|
||||
expect(screen.getByText(/role:read/)).toBeInTheDocument();
|
||||
it('renders with multiple denied permissions', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('role', 'admin')),
|
||||
buildPermission('update', buildObjectString('role', 'admin')),
|
||||
];
|
||||
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
|
||||
expect(screen.getByText(/read:role:admin/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/update:role:admin/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,16 +3,30 @@ import { CircleSlash2 } from '@signozhq/icons';
|
||||
import styles from './PermissionDeniedFullPage.module.scss';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
interface PermissionDeniedFullPageProps {
|
||||
permissionName: string;
|
||||
export interface PermissionDeniedFullPageProps {
|
||||
/**
|
||||
* @deprecated Use `deniedPermissions` instead. Will be removed after authz devtools PR merges.
|
||||
*/
|
||||
permissionName?: string;
|
||||
deniedPermissions?: BrandedPermission[];
|
||||
}
|
||||
|
||||
function PermissionDeniedFullPage({
|
||||
permissionName,
|
||||
deniedPermissions,
|
||||
}: PermissionDeniedFullPageProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
// TODO(authz): Remove permissionName support after devtools PR merges
|
||||
const formattedPermissions = deniedPermissions
|
||||
? deniedPermissions.map(formatPermission)
|
||||
: permissionName
|
||||
? [permissionName]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
@@ -22,7 +36,13 @@ function PermissionDeniedFullPage({
|
||||
<p className={styles.title}>Uh-oh! You are not authorized</p>
|
||||
<p className={styles.subtitle}>
|
||||
<code className={styles.permission}>user/{user.id}</code> is not authorized
|
||||
to perform <code className={styles.permission}>{permissionName}</code>
|
||||
to perform{' '}
|
||||
{formattedPermissions.map((perm, idx) => (
|
||||
<span key={perm}>
|
||||
<code className={styles.permission}>{perm}</code>
|
||||
{idx < formattedPermissions.length - 1 && ', '}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
185
frontend/src/lib/authz/components/README.md
Normal file
185
frontend/src/lib/authz/components/README.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# AuthZ Components
|
||||
|
||||
Quick reference for permission-gating UI. All components use AND semantics: user needs ALL permissions in `checks` array.
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Need to gate...
|
||||
├── A button? → AuthZButton
|
||||
├── Any element with tooltip on deny? → AuthZTooltip
|
||||
├── A section inside a page? → withAuthZContent (preferred)
|
||||
│ └── Need JSX wrapper? → AuthZGuardContent
|
||||
├── An entire page/route? → withAuthZPage (preferred)
|
||||
│ └── Need JSX wrapper? → AuthZGuardPage
|
||||
├── Need full control over fallback? → withAuthZ / AuthZGuard
|
||||
└── None of above fit?
|
||||
├── Can create wrapper component? → Create it (like AuthZButton)
|
||||
└── Last resort → useAuthZ hook directly
|
||||
```
|
||||
|
||||
## Building Permissions
|
||||
|
||||
Use `buildPermission`, `buildObjectString` or pre-built constants. Never cast with `as BrandedPermission`.
|
||||
|
||||
```tsx
|
||||
import { buildPermission, buildObjectString } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import {
|
||||
RoleCreatePermission,
|
||||
buildRoleReadPermission
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
// Static permission (pre-built)
|
||||
const checks = [RoleCreatePermission];
|
||||
|
||||
// Dynamic permission (builder fn)
|
||||
const checks = [buildRoleReadPermission(roleId)];
|
||||
|
||||
// Custom permission (buildPermission + buildObjectString)
|
||||
const checks = [buildPermission('read', buildObjectString('dashboard', dashboardId))];
|
||||
```
|
||||
|
||||
## Creating Permission Helpers
|
||||
|
||||
When adding authz to a new resource, create a permissions file under `lib/authz/hooks/useAuthZ/permissions/`.
|
||||
|
||||
```tsx
|
||||
// lib/authz/hooks/useAuthZ/permissions/dashboard.permissions.ts
|
||||
import { buildPermission } from '../utils';
|
||||
import type { BrandedPermission } from '../types';
|
||||
|
||||
// Collection-level — wildcard, no specific id needed
|
||||
export const DashboardCreatePermission = buildPermission('create', 'dashboard:*');
|
||||
export const DashboardListPermission = buildPermission('list', 'dashboard:*');
|
||||
|
||||
// Resource-level — require specific id
|
||||
export const buildDashboardReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `dashboard:${id}`);
|
||||
export const buildDashboardUpdatePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('update', `dashboard:${id}`);
|
||||
export const buildDashboardDeletePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('delete', `dashboard:${id}`);
|
||||
```
|
||||
|
||||
Pattern:
|
||||
- `<Resource><Action>Permission` → collection-level const (wildcard `*`)
|
||||
- `build<Resource><Action>Permission(id)` → resource-level fn (specific id)
|
||||
|
||||
## Components
|
||||
|
||||
### AuthZButton
|
||||
|
||||
Button that disables + shows tooltip when denied.
|
||||
|
||||
```tsx
|
||||
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
|
||||
<AuthZButton checks={[SACreatePermission]} onClick={handleCreate}>
|
||||
Create
|
||||
</AuthZButton>
|
||||
```
|
||||
|
||||
### AuthZTooltip
|
||||
|
||||
Wraps any element. Disables child + shows denial tooltip.
|
||||
|
||||
```tsx
|
||||
import { buildSADeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
|
||||
<AuthZTooltip checks={[buildSADeletePermission(accountId)]}>
|
||||
<IconButton icon={<Trash />} onClick={handleDelete} />
|
||||
</AuthZTooltip>
|
||||
```
|
||||
|
||||
### withAuthZPage (preferred for pages)
|
||||
|
||||
HOC for route-level gating. Wrap at export. Shows `PermissionDeniedFullPage` + `AppLoading`.
|
||||
|
||||
```tsx
|
||||
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
function RolesPage(): JSX.Element {
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
export default withAuthZPage(RolesPage, {
|
||||
checks: [RoleListPermission],
|
||||
});
|
||||
```
|
||||
|
||||
### withAuthZContent (preferred for sections)
|
||||
|
||||
HOC for inline sections. Shows `PermissionDeniedCallout` on deny.
|
||||
|
||||
```tsx
|
||||
import { buildRoleReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
function RoleEditor(): JSX.Element {
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
// Dynamic checks from route params
|
||||
export default withAuthZContent(RoleEditor, {
|
||||
checks: (_props, ctx) => [buildRoleReadPermission(ctx.params.roleId)],
|
||||
});
|
||||
```
|
||||
|
||||
### withAuthZ
|
||||
|
||||
HOC base. No default fallback. Use when you need custom fallback.
|
||||
|
||||
```tsx
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
export default withAuthZ(SecretPanel, {
|
||||
checks: [buildPermission('write', 'settings:org')],
|
||||
fallback: <p>No access</p>,
|
||||
});
|
||||
```
|
||||
|
||||
### AuthZGuardPage
|
||||
|
||||
JSX variant of `withAuthZPage`. Use when HOC not possible (conditional rendering).
|
||||
|
||||
```tsx
|
||||
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
<AuthZGuardPage checks={[RoleListPermission]}>
|
||||
<RolesPage />
|
||||
</AuthZGuardPage>
|
||||
```
|
||||
|
||||
### AuthZGuardContent
|
||||
|
||||
JSX variant of `withAuthZContent`. Use when HOC not possible.
|
||||
|
||||
```tsx
|
||||
import { RoleCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
<AuthZGuardContent checks={[RoleCreatePermission]}>
|
||||
<RoleEditor />
|
||||
</AuthZGuardContent>
|
||||
```
|
||||
|
||||
### AuthZGuard
|
||||
|
||||
JSX base guard. No default fallback. Use when you need custom fallback in JSX.
|
||||
|
||||
```tsx
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
<AuthZGuard
|
||||
checks={[buildPermission('write', 'settings:org')]}
|
||||
fallback={<p>No access</p>}
|
||||
fallbackOnLoading={<Spinner />}
|
||||
>
|
||||
<SecretContent />
|
||||
</AuthZGuard>
|
||||
```
|
||||
|
||||
## Fallback Components
|
||||
|
||||
Don't use these components directly, always prefer using via `withAuthZ` and their variants.
|
||||
|
||||
- PermissionDeniedCallout: inline error callout. Shows `user/{id} is not authorized to perform {permissions}`.
|
||||
- PermissionDeniedFullPage: full-page centered error. Same message, bigger presentation.
|
||||
@@ -1,440 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import type { RouteComponentProps } from 'react-router-dom';
|
||||
import type {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import { createGuardedRoute } from './createGuardedRoute';
|
||||
|
||||
describe('createGuardedRoute', () => {
|
||||
const TestComponent = ({ testProp }: { testProp: string }): ReactElement => (
|
||||
<div>Test Component: {testProp}</div>
|
||||
);
|
||||
|
||||
it('should render component when permission is granted', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should substitute route parameters in object string', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'read',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '123' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/123',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple route parameters', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
const txn = payload[0];
|
||||
const responseData: AuthtypesGettableTransactionDTO[] = [
|
||||
{
|
||||
relation: txn.relation,
|
||||
object: {
|
||||
resource: {
|
||||
kind: txn.object.resource.kind,
|
||||
type: txn.object.resource.type,
|
||||
},
|
||||
selector: '123:456',
|
||||
},
|
||||
authorized: true,
|
||||
},
|
||||
];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: responseData, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'update',
|
||||
'role:{id}:{version}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '123', version: '456' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id/:version',
|
||||
url: '/dashboard/123/456',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep placeholder when route parameter is missing', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'read',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render loading fallback when loading', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.delay('infinite'),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
expect(screen.getByText('SigNoz')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Test Component: test-value'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the component when API error occurs (fail open)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render no permissions fallback when 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, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'update',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '123' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/123',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const heading = document.querySelector('h3');
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading?.textContent).toMatch(/not authorized/i);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/update:role:123/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Test Component: test-value'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass all props to wrapped component', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const ComponentWithMultipleProps = ({
|
||||
prop1,
|
||||
prop2,
|
||||
prop3,
|
||||
}: {
|
||||
prop1: string;
|
||||
prop2: number;
|
||||
prop3: boolean;
|
||||
}): ReactElement => (
|
||||
<div>
|
||||
{prop1} - {prop2} - {prop3.toString()}
|
||||
</div>
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
ComponentWithMultipleProps,
|
||||
'read',
|
||||
'role:*',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
prop1: 'value1',
|
||||
prop2: 42,
|
||||
prop3: true,
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('value1 - 42 - true')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should memoize resolved object based on route params', async () => {
|
||||
let requestCount = 0;
|
||||
const requestedObjects: string[] = [];
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount++;
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
const obj = payload[0]?.object;
|
||||
const kind = obj?.resource?.kind;
|
||||
const selector = obj?.selector ?? '*';
|
||||
const objectStr = `${kind}:${selector}`;
|
||||
requestedObjects.push(objectStr ?? '');
|
||||
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'read',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch1 = {
|
||||
params: { id: '123' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/123',
|
||||
};
|
||||
|
||||
const props1 = {
|
||||
testProp: 'test-value-1',
|
||||
match: mockMatch1,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
const { unmount } = render(<GuardedComponent {...props1} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(1);
|
||||
expect(requestedObjects).toContain('role:123');
|
||||
|
||||
unmount();
|
||||
|
||||
const mockMatch2 = {
|
||||
params: { id: '456' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/456',
|
||||
};
|
||||
|
||||
const props2 = {
|
||||
testProp: 'test-value-2',
|
||||
match: mockMatch2,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props2} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(requestedObjects).toContain('role:456');
|
||||
});
|
||||
|
||||
it('should handle different relation types', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'delete',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '789' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/789',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
.guard-authz-error-no-authz {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
padding: 24px;
|
||||
|
||||
.guard-authz-error-no-authz-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
color: var(--l1-foreground);
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--l3-foreground);
|
||||
line-height: 18px;
|
||||
|
||||
span {
|
||||
background-color: var(--l3-background);
|
||||
white-space: nowrap;
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { ComponentType, ReactElement, useMemo } from 'react';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import noDataUrl from 'assets/Icons/no-data.svg';
|
||||
|
||||
import AppLoading from '../../../../components/AppLoading/AppLoading';
|
||||
import { GuardAuthZ } from '../GuardAuthZ/GuardAuthZ';
|
||||
|
||||
import './createGuardedRoute.styles.scss';
|
||||
|
||||
function OnNoPermissionsFallback(response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement {
|
||||
const { user } = useAppContext();
|
||||
|
||||
return (
|
||||
<div className="guard-authz-error-no-authz">
|
||||
<div className="guard-authz-error-no-authz-content">
|
||||
<img src={noDataUrl} alt="No permission" />
|
||||
<h3>Uh-oh! You are not authorized</h3>
|
||||
<p>
|
||||
<code>user/{user.id}</code> is not authorized to perform{' '}
|
||||
<code>{formatPermission(response.requiredPermissionName)}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export function createGuardedRoute<P extends object, R extends AuthZRelation>(
|
||||
Component: ComponentType<P>,
|
||||
relation: R,
|
||||
object: AuthZObject<R>,
|
||||
): ComponentType<P & RouteComponentProps<Record<string, string>>> {
|
||||
return function GuardedRouteComponent(
|
||||
props: P & RouteComponentProps<Record<string, string>>,
|
||||
): ReactElement {
|
||||
const resolvedObject = useMemo(() => {
|
||||
const paramPattern = /\{([^}]+)\}/g;
|
||||
return object.replace(paramPattern, (match, paramName) => {
|
||||
const paramValue = props.match?.params?.[paramName];
|
||||
return paramValue !== undefined ? paramValue : match;
|
||||
}) as AuthZObject<R>;
|
||||
}, [props.match?.params]);
|
||||
|
||||
return (
|
||||
<GuardAuthZ
|
||||
relation={relation}
|
||||
object={resolvedObject}
|
||||
fallbackOnLoading={<AppLoading />}
|
||||
fallbackOnNoPermissions={(response): ReactElement => (
|
||||
<OnNoPermissionsFallback {...response} />
|
||||
)}
|
||||
>
|
||||
<Component {...props} />
|
||||
</GuardAuthZ>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, act } from 'tests/test-utils';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDeny,
|
||||
setupAuthzGrantByPrefix,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import {
|
||||
buildObjectString,
|
||||
buildPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
import { withAuthZ, RouterContext } from './withAuthZ';
|
||||
import { withAuthZContent } from './withAuthZContent';
|
||||
import { withAuthZPage } from './withAuthZPage';
|
||||
|
||||
const mockUseParams = jest.fn();
|
||||
const mockUseLocation = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: (): Record<string, string> => mockUseParams(),
|
||||
useLocation: (): { pathname: string; search: string } => mockUseLocation(),
|
||||
}));
|
||||
|
||||
const readPerm = buildPermission('read', 'role:*' as AuthZObject<'read'>);
|
||||
|
||||
function Base(): JSX.Element {
|
||||
return <div>Base component</div>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
|
||||
});
|
||||
|
||||
describe('withAuthZ', () => {
|
||||
it('renders the wrapped component when allowed', async () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
const Guarded = withAuthZ(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Base component')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing when denied without a fallback', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZ(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Base component')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the provided fallback when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZ(Base, {
|
||||
checks: [readPerm],
|
||||
fallback: <div>No access</div>,
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No access')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves checks from props via the selector form', async () => {
|
||||
type Props = { roleId: string };
|
||||
const RoleView = ({ roleId }: Props): JSX.Element => <div>role {roleId}</div>;
|
||||
const deniedPerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-1'),
|
||||
);
|
||||
server.use(setupAuthzDeny(deniedPerm));
|
||||
|
||||
const Guarded = withAuthZ<Props>(RoleView, {
|
||||
checks: ({ roleId }) => [
|
||||
buildPermission('read', buildObjectString<'read'>('role', roleId)),
|
||||
],
|
||||
fallback: <div>denied selector</div>,
|
||||
});
|
||||
|
||||
render(<Guarded roleId="r-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('denied selector')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('role r-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets a descriptive displayName', () => {
|
||||
const Guarded = withAuthZ(Base, { checks: [readPerm] });
|
||||
expect(Guarded.displayName).toBe('withAuthZ(Base)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZPage', () => {
|
||||
it('renders the full-page denied screen when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZPage(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Uh-oh! You are not authorized'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZContent', () => {
|
||||
it('renders the denied callout when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZContent(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('read:role:*')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Base component')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZ router context', () => {
|
||||
it('extracts checks from route params via router.params', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-123' });
|
||||
|
||||
const rolePerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-123'),
|
||||
);
|
||||
server.use(setupAuthzAllow(rolePerm));
|
||||
|
||||
const RoleView = (): JSX.Element => <div>role view</div>;
|
||||
const Guarded = withAuthZ(RoleView, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? ''),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('role view')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts checks from query params via router.searchParams', async () => {
|
||||
mockUseLocation.mockReturnValue({
|
||||
pathname: '/roles',
|
||||
search: '?roleId=r-456',
|
||||
});
|
||||
|
||||
const rolePerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-456'),
|
||||
);
|
||||
server.use(setupAuthzAllow(rolePerm));
|
||||
|
||||
const RoleListView = (): JSX.Element => <div>role list view</div>;
|
||||
const Guarded = withAuthZ(RoleListView, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.searchParams.get('roleId') ?? ''),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('role list view')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts checks from pathname via router.matchPath', async () => {
|
||||
mockUseLocation.mockReturnValue({
|
||||
pathname: '/settings/roles/r-789/edit',
|
||||
search: '',
|
||||
});
|
||||
|
||||
const rolePerm = buildPermission(
|
||||
'update',
|
||||
buildObjectString<'update'>('role', 'r-789'),
|
||||
);
|
||||
server.use(setupAuthzAllow(rolePerm));
|
||||
|
||||
const EditRoleView = (): JSX.Element => <div>edit role</div>;
|
||||
const Guarded = withAuthZ(EditRoleView, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
const match = router.matchPath<{ roleId: string }>(
|
||||
'/settings/roles/:roleId/edit',
|
||||
);
|
||||
return match
|
||||
? [
|
||||
buildPermission(
|
||||
'update',
|
||||
buildObjectString<'update'>('role', match.roleId),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
},
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('edit role')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('denies when router-derived permission is not allowed', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-denied' });
|
||||
|
||||
const deniedPerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-denied'),
|
||||
);
|
||||
server.use(setupAuthzDeny(deniedPerm));
|
||||
|
||||
const RoleView = (): JSX.Element => <div>role view</div>;
|
||||
const Guarded = withAuthZ(RoleView, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? ''),
|
||||
),
|
||||
],
|
||||
fallback: <div>access denied</div>,
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('access denied')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('role view')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZ router context stability', () => {
|
||||
let renderCount = 0;
|
||||
|
||||
function RenderCounter(): JSX.Element {
|
||||
renderCount += 1;
|
||||
return <div data-testid="render-count">{renderCount}</div>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
renderCount = 0;
|
||||
server.use(setupAuthzGrantByPrefix('read||__||role'));
|
||||
});
|
||||
|
||||
it('does not re-render when useParams returns new object with same values', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-1' });
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? '*'),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
|
||||
// Return NEW object with SAME values — should not cause re-render
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-1' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
// Allow any pending effects to flush
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-render when useLocation returns new object with same pathname', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
const match = router.matchPath<{ id: string }>('/roles/:id');
|
||||
return match
|
||||
? [buildPermission('read', buildObjectString<'read'>('role', match.id))]
|
||||
: [readPerm];
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
|
||||
// Return NEW object with SAME pathname — should not cause re-render
|
||||
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-render when useLocation returns new object with same search params', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
// Access searchParams to ensure it's part of the dependency chain
|
||||
void router.searchParams.get('tab');
|
||||
return [readPerm];
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
|
||||
// Return NEW object with SAME search — should not cause re-render
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('re-renders when params values actually change', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-1' });
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? '*'),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
unmount();
|
||||
|
||||
// Return DIFFERENT values — re-mount with new mock values
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-2' });
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('re-renders when pathname actually changes', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: [readPerm],
|
||||
});
|
||||
|
||||
const { unmount } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
unmount();
|
||||
|
||||
// DIFFERENT pathname — re-mount with new mock values
|
||||
mockUseLocation.mockReturnValue({ pathname: '/users', search: '' });
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('re-renders when search params actually change', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: [readPerm],
|
||||
});
|
||||
|
||||
const { unmount } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
unmount();
|
||||
|
||||
// DIFFERENT search — re-mount with new mock values
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=details' });
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZContent cache invalidation', () => {
|
||||
const testPerm = buildPermission(
|
||||
'read',
|
||||
'role:test-invalidation' as AuthZObject<'read'>,
|
||||
);
|
||||
// Callout displays permission as "relation:object" format
|
||||
const displayedPerm = 'read:role:test-invalidation';
|
||||
|
||||
function ContentComponent(): JSX.Element {
|
||||
return <div data-testid="protected-content">Protected Content</div>;
|
||||
}
|
||||
|
||||
function InvalidationTrigger({
|
||||
permission,
|
||||
onReady,
|
||||
}: {
|
||||
permission: string;
|
||||
onReady: (invalidate: () => Promise<void>) => void;
|
||||
}): null {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
React.useEffect(() => {
|
||||
onReady(async () => {
|
||||
// Reset query to initial state and trigger refetch (matches devtools behavior)
|
||||
await queryClient.resetQueries(['authz', permission]);
|
||||
});
|
||||
}, [queryClient, permission, onReady]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
it('re-renders from allowed to denied when cache is invalidated', async () => {
|
||||
let shouldGrant = true;
|
||||
let invalidateFn: (() => Promise<void>) | null = null;
|
||||
|
||||
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, [shouldGrant])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const Guarded = withAuthZContent(ContentComponent, { checks: [testPerm] });
|
||||
|
||||
render(
|
||||
<>
|
||||
<Guarded />
|
||||
<InvalidationTrigger
|
||||
permission={testPerm}
|
||||
onReady={(fn): void => {
|
||||
invalidateFn = fn;
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially allowed - should show content
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change server response to deny
|
||||
shouldGrant = false;
|
||||
|
||||
// Invalidate cache
|
||||
await act(async () => {
|
||||
await invalidateFn?.();
|
||||
});
|
||||
|
||||
// Should now show denied callout
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Callout should show the denied permission
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText(displayedPerm)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-renders from denied to allowed when cache is invalidated', async () => {
|
||||
let shouldGrant = false;
|
||||
let invalidateFn: (() => Promise<void>) | null = null;
|
||||
|
||||
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, [shouldGrant])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const Guarded = withAuthZContent(ContentComponent, { checks: [testPerm] });
|
||||
|
||||
render(
|
||||
<>
|
||||
<Guarded />
|
||||
<InvalidationTrigger
|
||||
permission={testPerm}
|
||||
onReady={(fn): void => {
|
||||
invalidateFn = fn;
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially denied - should show callout
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
||||
|
||||
// Change server response to allow
|
||||
shouldGrant = true;
|
||||
|
||||
// Invalidate cache
|
||||
await act(async () => {
|
||||
await invalidateFn?.();
|
||||
});
|
||||
|
||||
// Should now show protected content
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
13
frontend/src/lib/authz/components/withAuthZ/withAuthZ.tsx
Normal file
13
frontend/src/lib/authz/components/withAuthZ/withAuthZ.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { AuthZGuard } from 'lib/authz/components/AuthZGuard/AuthZGuard';
|
||||
|
||||
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export type { RouterContext, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export function withAuthZ<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
return createAuthZHOC(AuthZGuard, 'withAuthZ', Component, opts);
|
||||
}
|
||||
108
frontend/src/lib/authz/components/withAuthZ/withAuthZ.utils.tsx
Normal file
108
frontend/src/lib/authz/components/withAuthZ/withAuthZ.utils.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { ComponentType, ReactElement, createElement, useMemo } from 'react';
|
||||
import {
|
||||
matchPath as reactRouterMatchPath,
|
||||
useLocation,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import type { AuthZGuardProps } from 'lib/authz/components/AuthZGuard/AuthZGuard';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
export type RouterContext = {
|
||||
/**
|
||||
* Route params from useParams (e.g. `/roles/:roleId` → `{ roleId: "r-1" }`)
|
||||
*/
|
||||
params: Record<string, string | undefined>;
|
||||
pathname: string;
|
||||
/**
|
||||
* Query params as URLSearchParams (use `.get('key')` to read)
|
||||
*/
|
||||
searchParams: URLSearchParams;
|
||||
/**
|
||||
* Extract params from pathname using a route pattern.
|
||||
* Returns null if pattern doesn't match.
|
||||
* @example router.matchPath<{ id: string }>('/edit/:id')?.id
|
||||
*/
|
||||
matchPath: <Params extends Record<string, string>>(
|
||||
pattern: string,
|
||||
) => Params | null;
|
||||
};
|
||||
|
||||
export type WithAuthZOptions<P> = {
|
||||
/**
|
||||
* Static checks, or a selector deriving them from props and router context.
|
||||
* Use router context to extract dynamic values from route params, pathname, or query params.
|
||||
* @example
|
||||
* // From route params
|
||||
* checks: (props, router) => [buildPermission('read', `role:${router.params.roleId}`)]
|
||||
* // From query params
|
||||
* checks: (props, router) => [buildPermission('read', `dashboard:${router.searchParams.get('id')}`)]
|
||||
* // From pathname matching
|
||||
* checks: (props, router) => {
|
||||
* const match = router.matchPath<{ id: string }>('/edit/:id');
|
||||
* return match ? [buildPermission('update', `role:${match.id}`)] : [];
|
||||
* }
|
||||
*/
|
||||
checks:
|
||||
| BrandedPermission[]
|
||||
| ((props: P, router: RouterContext) => BrandedPermission[]);
|
||||
fallback?: AuthZGuardProps['fallback'];
|
||||
fallbackOnLoading?: AuthZGuardProps['fallbackOnLoading'];
|
||||
failOpenOnError?: AuthZGuardProps['onFailRenderContent'];
|
||||
};
|
||||
|
||||
function useStableParams(): Record<string, string | undefined> {
|
||||
const params = useParams();
|
||||
const paramsJson = JSON.stringify(params);
|
||||
return useMemo(() => JSON.parse(paramsJson), [paramsJson]);
|
||||
}
|
||||
|
||||
function useRouterContext(): RouterContext {
|
||||
const params = useStableParams();
|
||||
const { pathname, search } = useLocation();
|
||||
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
|
||||
|
||||
return useMemo(
|
||||
(): RouterContext => ({
|
||||
params,
|
||||
pathname,
|
||||
searchParams,
|
||||
matchPath: <Params extends Record<string, string>>(
|
||||
pattern: string,
|
||||
): Params | null => {
|
||||
const match = reactRouterMatchPath<Params>(pathname, {
|
||||
path: pattern,
|
||||
exact: false,
|
||||
});
|
||||
return match?.params ?? null;
|
||||
},
|
||||
}),
|
||||
[params, pathname, searchParams],
|
||||
);
|
||||
}
|
||||
|
||||
export function createAuthZHOC<P extends object>(
|
||||
Guard: ComponentType<AuthZGuardProps>,
|
||||
hocName: string,
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
const { checks, ...guardProps } = opts;
|
||||
|
||||
function Wrapped(props: P): ReactElement | null {
|
||||
const router = useRouterContext();
|
||||
const resolvedChecks =
|
||||
typeof checks === 'function' ? checks(props, router) : checks;
|
||||
|
||||
return (
|
||||
<Guard checks={resolvedChecks} {...guardProps}>
|
||||
{createElement(Component, props)}
|
||||
</Guard>
|
||||
);
|
||||
}
|
||||
|
||||
Wrapped.displayName = `${hocName}(${
|
||||
Component.displayName || Component.name || 'Component'
|
||||
})`;
|
||||
|
||||
return Wrapped;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
|
||||
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export function withAuthZContent<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
return createAuthZHOC(AuthZGuardContent, 'withAuthZContent', Component, opts);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { AuthZGuardPage } from 'lib/authz/components/AuthZGuard/AuthZGuardPage';
|
||||
|
||||
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export function withAuthZPage<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
return createAuthZHOC(AuthZGuardPage, 'withAuthZPage', Component, opts);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9998;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.button {
|
||||
box-shadow:
|
||||
0 4px 12px rgb(0 0 0 / 15%),
|
||||
0 0 0 1px rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 4px 12px rgb(0 0 0 / 15%),
|
||||
0 0 0 1px rgb(0 0 0 / 5%);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useAuthZDevStore } from '../useAuthZDevStore';
|
||||
|
||||
import styles from './AuthZDevFloatingIndicator.module.css';
|
||||
|
||||
export function AuthZDevFloatingIndicator(): JSX.Element | null {
|
||||
const overrides = useAuthZDevStore((s) => s.overrides);
|
||||
const isModalOpen = useAuthZDevStore((s) => s.isModalOpen);
|
||||
const openModal = useAuthZDevStore((s) => s.openModal);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
const overrideCount = Object.keys(overrides).length;
|
||||
|
||||
if (overrideCount === 0 || isModalOpen || isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleOpen = (): void => {
|
||||
setIsDismissed(false);
|
||||
openModal();
|
||||
};
|
||||
|
||||
const handleDismiss = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
setIsDismissed(true);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.container}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="warning"
|
||||
size="sm"
|
||||
onClick={handleOpen}
|
||||
className={styles.button}
|
||||
data-testid="authz-dev-floating-indicator"
|
||||
>
|
||||
AuthZ Overrides
|
||||
<Badge color="warning" className={styles.badge}>
|
||||
{overrideCount}
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
className={styles.closeButton}
|
||||
aria-label="Dismiss indicator"
|
||||
data-testid="authz-dev-floating-dismiss"
|
||||
prefix={<X />}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
.modal {
|
||||
--dialog-width: 640px;
|
||||
--dialog-max-width: 92vw;
|
||||
--dialog-max-height: 78vh;
|
||||
--dialog-description-padding: var(--spacing-4) var(--spacing-4) 0px
|
||||
var(--spacing-4);
|
||||
|
||||
[data-slot='dialog-description'],
|
||||
[data-slot='dialog-header'] {
|
||||
background-color: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(78vh - 80px);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
gap: var(--spacing-4);
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.searchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
|
||||
--input-background: var(--l3-background);
|
||||
--input-hover-background: var(--l3-background);
|
||||
--input-focus-background: var(--l3-background);
|
||||
--input-border-color: var(--l3-border);
|
||||
--input-hover-border-color: var(--l3-border);
|
||||
--input-focus-border-color: var(--l3-border);
|
||||
|
||||
--select-trigger-background-color: var(--l3-background);
|
||||
--select-trigger-hover-background: var(--l3-background);
|
||||
--select-trigger-focus-background: var(--l3-background);
|
||||
--select-trigger-border-color: var(--l3-border);
|
||||
|
||||
--select-content-background: var(--l3-background);
|
||||
--select-item-highlight-background: var(--l3-background-hover);
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
--input-width: 100%;
|
||||
}
|
||||
|
||||
.filter {
|
||||
flex: 0 0 176px;
|
||||
/* Normalize the library trigger height (2.25rem) to match the input. */
|
||||
--select-trigger-height: 2rem;
|
||||
}
|
||||
|
||||
.search > *,
|
||||
.filter > * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
display: inline-flex;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.actionsRow {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
flex: 0 0 auto;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-4) 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-2);
|
||||
margin: 0 0 var(--spacing-1);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
padding: var(--spacing-16);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) 0;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.hintGroup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.count {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
108
frontend/src/lib/authz/devtools/AuthZDevModal/AuthZDevModal.tsx
Normal file
108
frontend/src/lib/authz/devtools/AuthZDevModal/AuthZDevModal.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { useAuthZDevStore } from '../useAuthZDevStore';
|
||||
import { useAuthZQueryInvalidation } from '../useAuthZQueryInvalidation';
|
||||
|
||||
import { AuthZDevModalContent } from './AuthZDevModalContent';
|
||||
import { AuthZDevModalFooter } from './AuthZDevModalFooter';
|
||||
import { AuthZDevModalHeader } from './AuthZDevModalHeader';
|
||||
import { useAuthZDevModalData } from './useAuthZDevModalData';
|
||||
import { useModalKeyboard } from './useModalKeyboard';
|
||||
|
||||
import styles from './AuthZDevModal.module.css';
|
||||
|
||||
export function AuthZDevModal(): JSX.Element | null {
|
||||
const isModalOpen = useAuthZDevStore((s) => s.isModalOpen);
|
||||
const closeModal = useAuthZDevStore((s) => s.closeModal);
|
||||
const observed = useAuthZDevStore((s) => s.observed);
|
||||
const overrides = useAuthZDevStore((s) => s.overrides);
|
||||
const cycleOverride = useAuthZDevStore((s) => s.cycleOverride);
|
||||
const setOverride = useAuthZDevStore((s) => s.setOverride);
|
||||
const clearAllOverrides = useAuthZDevStore((s) => s.clearAllOverrides);
|
||||
const grantAll = useAuthZDevStore((s) => s.grantAll);
|
||||
const denyAll = useAuthZDevStore((s) => s.denyAll);
|
||||
|
||||
useAuthZQueryInvalidation(overrides);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
resourceFilter,
|
||||
setResourceFilter,
|
||||
observedList,
|
||||
resourceFilterItems,
|
||||
filteredPermissions,
|
||||
groups,
|
||||
orderedPermissions,
|
||||
indexByPermission,
|
||||
hasActiveFilter,
|
||||
filteredOverrideCount,
|
||||
overrideCount,
|
||||
} = useAuthZDevModalData(observed, overrides);
|
||||
|
||||
const { selectedIndex, setSelectedIndex } = useModalKeyboard({
|
||||
permissions: orderedPermissions,
|
||||
overrides,
|
||||
onCycle: cycleOverride,
|
||||
onSetOverride: setOverride,
|
||||
onClose: closeModal,
|
||||
searchInputRef,
|
||||
});
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean): void => {
|
||||
if (!open) {
|
||||
closeModal();
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
},
|
||||
[closeModal, setSelectedIndex],
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={isModalOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
title="AuthZ DevTools"
|
||||
subTitle="Force permission results locally without touching the backend."
|
||||
className={styles.modal}
|
||||
width="wide"
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<AuthZDevModalHeader
|
||||
searchInputRef={searchInputRef}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
resourceFilter={resourceFilter}
|
||||
setResourceFilter={setResourceFilter}
|
||||
resourceFilterItems={resourceFilterItems}
|
||||
hasActiveFilter={hasActiveFilter}
|
||||
filteredPermissions={filteredPermissions}
|
||||
filteredOverrideCount={filteredOverrideCount}
|
||||
overrideCount={overrideCount}
|
||||
grantAll={grantAll}
|
||||
denyAll={denyAll}
|
||||
clearAllOverrides={clearAllOverrides}
|
||||
/>
|
||||
<AuthZDevModalContent
|
||||
observedListLength={observedList.length}
|
||||
orderedPermissions={orderedPermissions}
|
||||
groups={groups}
|
||||
indexByPermission={indexByPermission}
|
||||
selectedIndex={selectedIndex}
|
||||
setSelectedIndex={setSelectedIndex}
|
||||
observed={observed}
|
||||
overrides={overrides}
|
||||
onSetOverride={setOverride}
|
||||
/>
|
||||
<AuthZDevModalFooter
|
||||
orderedPermissionsCount={orderedPermissions.length}
|
||||
observedListLength={observedList.length}
|
||||
/>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { BrandedPermission } from '../../hooks/useAuthZ/types';
|
||||
import { ObservedPermission, OverrideState } from '../types';
|
||||
|
||||
import { PermissionRow } from './PermissionRow';
|
||||
|
||||
import styles from './AuthZDevModal.module.css';
|
||||
|
||||
export interface PermissionGroup {
|
||||
resource: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface AuthZDevModalContentProps {
|
||||
observedListLength: number;
|
||||
orderedPermissions: string[];
|
||||
groups: PermissionGroup[];
|
||||
indexByPermission: Map<string, number>;
|
||||
selectedIndex: number;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
observed: Record<string, ObservedPermission>;
|
||||
overrides: Record<string, OverrideState>;
|
||||
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
|
||||
}
|
||||
|
||||
export function AuthZDevModalContent({
|
||||
observedListLength,
|
||||
orderedPermissions,
|
||||
groups,
|
||||
indexByPermission,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
observed,
|
||||
overrides,
|
||||
onSetOverride,
|
||||
}: AuthZDevModalContentProps): JSX.Element {
|
||||
const handleSelectIndex = useCallback(
|
||||
(index: number) => (): void => {
|
||||
setSelectedIndex(index);
|
||||
},
|
||||
[setSelectedIndex],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.list} data-testid="authz-dev-permission-list">
|
||||
{orderedPermissions.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<Typography.Text align="center" color="muted">
|
||||
{observedListLength === 0
|
||||
? 'No permissions observed yet. Navigate the app to trigger permission checks.'
|
||||
: 'No permissions match your search.'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<div key={group.resource} className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Typography.Text as="span" size="medium" weight="semibold">
|
||||
{group.resource}
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
{group.items.length}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{group.items.map((permission) => {
|
||||
const index = indexByPermission.get(permission) ?? 0;
|
||||
return (
|
||||
<PermissionRow
|
||||
key={permission}
|
||||
observed={observed[permission]}
|
||||
override={overrides[permission]}
|
||||
isSelected={index === selectedIndex}
|
||||
onSetOverride={onSetOverride}
|
||||
onSelect={handleSelectIndex(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Kbd } from '@signozhq/ui/kbd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './AuthZDevModal.module.css';
|
||||
|
||||
export interface AuthZDevModalFooterProps {
|
||||
orderedPermissionsCount: number;
|
||||
observedListLength: number;
|
||||
}
|
||||
|
||||
export function AuthZDevModalFooter({
|
||||
orderedPermissionsCount,
|
||||
observedListLength,
|
||||
}: AuthZDevModalFooterProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.hint}>
|
||||
<span className={styles.hintGroup}>
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>↓</Kbd>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
navigate
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span className={styles.hintGroup}>
|
||||
<Kbd>←</Kbd>
|
||||
<Kbd>→</Kbd>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
mode
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span className={styles.hintGroup}>
|
||||
<Kbd>1-5</Kbd>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
set
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span className={styles.hintGroup}>
|
||||
<Kbd>/</Kbd>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
search
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span className={styles.hintGroup}>
|
||||
<Kbd>Esc</Kbd>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
close
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</div>
|
||||
<Typography.Text size="small" color="muted" className={styles.count}>
|
||||
{orderedPermissionsCount} of {observedListLength} permissions
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Search } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
import { BrandedPermission } from '../../hooks/useAuthZ/types';
|
||||
|
||||
import styles from './AuthZDevModal.module.css';
|
||||
|
||||
export interface AuthZDevModalHeaderProps {
|
||||
searchInputRef: RefObject<HTMLInputElement>;
|
||||
search: string;
|
||||
setSearch: (value: string) => void;
|
||||
resourceFilter: string;
|
||||
setResourceFilter: (value: string) => void;
|
||||
resourceFilterItems: Array<{ value: string; label: string }>;
|
||||
hasActiveFilter: boolean;
|
||||
filteredPermissions: BrandedPermission[];
|
||||
filteredOverrideCount: number;
|
||||
overrideCount: number;
|
||||
grantAll: (permissions?: BrandedPermission[]) => void;
|
||||
denyAll: (permissions?: BrandedPermission[]) => void;
|
||||
clearAllOverrides: (permissions?: BrandedPermission[]) => void;
|
||||
}
|
||||
|
||||
export function AuthZDevModalHeader({
|
||||
searchInputRef,
|
||||
search,
|
||||
setSearch,
|
||||
resourceFilter,
|
||||
setResourceFilter,
|
||||
resourceFilterItems,
|
||||
hasActiveFilter,
|
||||
filteredPermissions,
|
||||
filteredOverrideCount,
|
||||
overrideCount,
|
||||
grantAll,
|
||||
denyAll,
|
||||
clearAllOverrides,
|
||||
}: AuthZDevModalHeaderProps): JSX.Element {
|
||||
const handleGrantAll = useCallback((): void => {
|
||||
grantAll(hasActiveFilter ? filteredPermissions : undefined);
|
||||
}, [grantAll, hasActiveFilter, filteredPermissions]);
|
||||
|
||||
const handleDenyAll = useCallback((): void => {
|
||||
denyAll(hasActiveFilter ? filteredPermissions : undefined);
|
||||
}, [denyAll, hasActiveFilter, filteredPermissions]);
|
||||
|
||||
const handleClearAll = useCallback((): void => {
|
||||
clearAllOverrides(hasActiveFilter ? filteredPermissions : undefined);
|
||||
}, [clearAllOverrides, hasActiveFilter, filteredPermissions]);
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.searchRow}>
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search permissions..."
|
||||
value={search}
|
||||
onChange={(e): void => setSearch(e.target.value)}
|
||||
prefix={<Search size={14} className={styles.searchIcon} />}
|
||||
aria-label="Search permissions"
|
||||
data-testid="authz-dev-search"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filter}>
|
||||
<SelectSimple
|
||||
items={resourceFilterItems}
|
||||
value={resourceFilter}
|
||||
onChange={(value): void => setResourceFilter(value as string)}
|
||||
testId="authz-dev-resource-filter"
|
||||
withPortal={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actionsRow}>
|
||||
<Button
|
||||
className={styles.actionButton}
|
||||
variant="outlined"
|
||||
color="success"
|
||||
size="sm"
|
||||
onClick={handleGrantAll}
|
||||
disabled={filteredPermissions.length === 0}
|
||||
data-testid="authz-dev-grant-all"
|
||||
>
|
||||
{hasActiveFilter ? 'Grant filtered' : 'Grant all'}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.actionButton}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="sm"
|
||||
onClick={handleDenyAll}
|
||||
disabled={filteredPermissions.length === 0}
|
||||
data-testid="authz-dev-deny-all"
|
||||
>
|
||||
{hasActiveFilter ? 'Deny filtered' : 'Deny all'}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.actionButton}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
disabled={
|
||||
hasActiveFilter ? filteredOverrideCount === 0 : overrideCount === 0
|
||||
}
|
||||
data-testid="authz-dev-clear-all"
|
||||
>
|
||||
{hasActiveFilter
|
||||
? `Clear filtered (${filteredOverrideCount})`
|
||||
: `Clear all (${overrideCount})`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-1);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.segment {
|
||||
--button-height: 22px;
|
||||
--button-padding: 0 var(--spacing-3);
|
||||
--button-secondary-ghost-hover-foreground: var(--l2-foreground);
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
border: none;
|
||||
--button-border-radius: calc(var(--radius-2) - 1px);
|
||||
transition:
|
||||
background 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
.segment:not(.segmentActive):hover {
|
||||
--button-secondary-ghost-hover-foreground: var(--l2-foreground-hover);
|
||||
--button-variant-ghost-background-color: var(--l2-background);
|
||||
}
|
||||
|
||||
.segmentIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.segment.optAuto {
|
||||
--button-secondary-ghost-hover-foreground: var(--l1-foreground);
|
||||
--button-variant-ghost-background-color: var(--l3-background);
|
||||
}
|
||||
|
||||
.segment.optGranted {
|
||||
--button-secondary-ghost-hover-foreground: var(--success-foreground);
|
||||
--button-variant-ghost-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-forest) 22%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.segment.optDenied {
|
||||
--button-secondary-ghost-hover-foreground: var(--danger-foreground);
|
||||
--button-variant-ghost-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-cherry) 22%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.segment.optDelay {
|
||||
--button-secondary-ghost-hover-foreground: var(--warning-foreground);
|
||||
--button-variant-ghost-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-amber) 22%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.segment.optError {
|
||||
--button-secondary-ghost-hover-foreground: var(--danger-foreground);
|
||||
--button-variant-ghost-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-cherry) 22%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Check, Clock, RotateCcw, X, Zap } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
|
||||
import { OverrideState } from '../types';
|
||||
|
||||
import styles from './OverrideControl.module.css';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
type OverrideControlProps = {
|
||||
permission: BrandedPermission;
|
||||
value: OverrideState;
|
||||
onSelect: (permission: BrandedPermission, state: OverrideState) => void;
|
||||
};
|
||||
|
||||
type OverrideOption = {
|
||||
state: OverrideState;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
activeClassName: string;
|
||||
};
|
||||
|
||||
const OVERRIDE_OPTIONS: OverrideOption[] = [
|
||||
{
|
||||
state: OverrideState.Reset,
|
||||
label: 'Auto',
|
||||
icon: <RotateCcw size={13} />,
|
||||
activeClassName: styles.optAuto,
|
||||
},
|
||||
{
|
||||
state: OverrideState.Granted,
|
||||
label: 'Grant',
|
||||
icon: <Check size={13} />,
|
||||
activeClassName: styles.optGranted,
|
||||
},
|
||||
{
|
||||
state: OverrideState.Denied,
|
||||
label: 'Deny',
|
||||
icon: <X size={13} />,
|
||||
activeClassName: styles.optDenied,
|
||||
},
|
||||
{
|
||||
state: OverrideState.Delay,
|
||||
label: 'Delay',
|
||||
icon: <Clock size={13} />,
|
||||
activeClassName: styles.optDelay,
|
||||
},
|
||||
{
|
||||
state: OverrideState.Error,
|
||||
label: 'Error',
|
||||
icon: <Zap size={13} />,
|
||||
activeClassName: styles.optError,
|
||||
},
|
||||
];
|
||||
|
||||
export function OverrideControl({
|
||||
permission,
|
||||
value,
|
||||
onSelect,
|
||||
}: OverrideControlProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.segmented}>
|
||||
{OVERRIDE_OPTIONS.map((option) => {
|
||||
const isActive = value === option.state;
|
||||
return (
|
||||
<Button
|
||||
key={option.state}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
className={cx(styles.segment, {
|
||||
[styles.segmentActive]: isActive,
|
||||
[option.activeClassName]: isActive,
|
||||
})}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={(): void => onSelect(permission, option.state)}
|
||||
data-testid={`override-${option.state}-${permission}`}
|
||||
>
|
||||
<div className={styles.segmentIcon}>{option.icon}</div>
|
||||
{isActive && (
|
||||
<Typography.Text as="span" size="small" weight="medium">
|
||||
{option.label}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
.permissionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-2);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.permissionRow:hover {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
|
||||
/* Overridden rows carry a faint full border in the override color. */
|
||||
.permissionRow.rowGranted {
|
||||
border-color: color-mix(in srgb, var(--accent-forest) 45%, transparent);
|
||||
}
|
||||
|
||||
.permissionRow.rowDenied {
|
||||
border-color: color-mix(in srgb, var(--accent-cherry) 45%, transparent);
|
||||
}
|
||||
|
||||
.permissionRow.rowDelay {
|
||||
border-color: color-mix(in srgb, var(--accent-amber) 45%, transparent);
|
||||
}
|
||||
|
||||
.permissionRow.rowError {
|
||||
border-color: color-mix(in srgb, var(--accent-cherry) 45%, transparent);
|
||||
}
|
||||
|
||||
/* Keyboard selection wins over the override border. */
|
||||
.permissionRow.isSelected {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.permissionInfo {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.relation {
|
||||
flex: 0 0 auto;
|
||||
--typography-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.object {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.permissionMeta {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
114
frontend/src/lib/authz/devtools/AuthZDevModal/PermissionRow.tsx
Normal file
114
frontend/src/lib/authz/devtools/AuthZDevModal/PermissionRow.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Badge, BadgeColor } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
|
||||
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
|
||||
import { parsePermission } from '../../hooks/useAuthZ/utils';
|
||||
import { OverrideState, type ObservedPermission } from '../types';
|
||||
|
||||
import { OverrideControl } from './OverrideControl';
|
||||
|
||||
import styles from './PermissionRow.module.css';
|
||||
|
||||
type PermissionRowProps = {
|
||||
observed: ObservedPermission;
|
||||
override: OverrideState | undefined;
|
||||
isSelected: boolean;
|
||||
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const ROW_OVERRIDE_CLASSES: Record<OverrideState, string | null> = {
|
||||
[OverrideState.Reset]: null,
|
||||
[OverrideState.Granted]: styles.rowGranted,
|
||||
[OverrideState.Denied]: styles.rowDenied,
|
||||
[OverrideState.Delay]: styles.rowDelay,
|
||||
[OverrideState.Error]: styles.rowError,
|
||||
};
|
||||
|
||||
export const PermissionRow = memo(function PermissionRow({
|
||||
observed,
|
||||
override,
|
||||
isSelected,
|
||||
onSetOverride,
|
||||
onSelect,
|
||||
}: PermissionRowProps): JSX.Element {
|
||||
const currentState = override ?? OverrideState.Reset;
|
||||
|
||||
const { relation, objectId } = useMemo(() => {
|
||||
const parsed = parsePermission(observed.permission);
|
||||
const separatorIndex = parsed.object.indexOf(':');
|
||||
return {
|
||||
relation: parsed.relation,
|
||||
objectId:
|
||||
separatorIndex === -1
|
||||
? parsed.object
|
||||
: parsed.object.slice(separatorIndex + 1),
|
||||
};
|
||||
}, [observed.permission]);
|
||||
|
||||
const handleSetOverride = useCallback(
|
||||
(permission: BrandedPermission, state: OverrideState): void => {
|
||||
onSelect();
|
||||
onSetOverride(permission, state);
|
||||
},
|
||||
[onSelect, onSetOverride],
|
||||
);
|
||||
|
||||
let apiColor: BadgeColor = 'secondary';
|
||||
let apiLabel = 'API ?';
|
||||
if (observed.apiValue === true) {
|
||||
apiColor = 'success';
|
||||
apiLabel = 'API ✓';
|
||||
} else if (observed.apiValue === false) {
|
||||
apiColor = 'error';
|
||||
apiLabel = 'API ✗';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.permissionRow, ROW_OVERRIDE_CLASSES[currentState], {
|
||||
[styles.isSelected]: isSelected,
|
||||
})}
|
||||
data-testid={`permission-row-${observed.permission}`}
|
||||
>
|
||||
<div className={styles.permissionInfo}>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.relation}
|
||||
>
|
||||
{relation}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.separator}
|
||||
>
|
||||
:
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
truncate={1}
|
||||
className={styles.object}
|
||||
>
|
||||
{objectId}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.permissionMeta}>
|
||||
<Badge variant="outline" color={apiColor}>
|
||||
{apiLabel}
|
||||
</Badge>
|
||||
<OverrideControl
|
||||
permission={observed.permission}
|
||||
value={currentState}
|
||||
onSelect={handleSetOverride}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
|
||||
import { parsePermission } from '../../hooks/useAuthZ/utils';
|
||||
import type { ObservedPermission, OverrideState } from '../types';
|
||||
|
||||
type SelectItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type PermissionGroup = {
|
||||
resource: string;
|
||||
items: BrandedPermission[];
|
||||
};
|
||||
|
||||
type UseAuthZDevModalDataResult = {
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
resourceFilter: string;
|
||||
setResourceFilter: (filter: string) => void;
|
||||
observedList: ObservedPermission[];
|
||||
resourceFilterItems: SelectItem[];
|
||||
filteredPermissions: BrandedPermission[];
|
||||
groups: PermissionGroup[];
|
||||
orderedPermissions: BrandedPermission[];
|
||||
indexByPermission: Map<string, number>;
|
||||
hasActiveFilter: boolean;
|
||||
filteredOverrideCount: number;
|
||||
overrideCount: number;
|
||||
};
|
||||
|
||||
export function useAuthZDevModalData(
|
||||
observed: Record<string, ObservedPermission>,
|
||||
overrides: Record<string, OverrideState>,
|
||||
): UseAuthZDevModalDataResult {
|
||||
const [search, setSearch] = useState('');
|
||||
const [resourceFilter, setResourceFilter] = useState<string>('all');
|
||||
|
||||
const observedList = useMemo(
|
||||
() =>
|
||||
Object.values(observed).sort((a, b) =>
|
||||
a.permission.localeCompare(b.permission),
|
||||
),
|
||||
[observed],
|
||||
);
|
||||
|
||||
const resources = useMemo(() => {
|
||||
const resourceSet = new Set<string>();
|
||||
for (const obs of observedList) {
|
||||
const { object } = parsePermission(obs.permission);
|
||||
const resource = object.split(':')[0];
|
||||
resourceSet.add(resource);
|
||||
}
|
||||
return Array.from(resourceSet).sort();
|
||||
}, [observedList]);
|
||||
|
||||
const resourceFilterItems = useMemo<SelectItem[]>(
|
||||
() => [
|
||||
{ value: 'all', label: 'All resources' },
|
||||
...resources.map((resource) => ({
|
||||
value: resource,
|
||||
label: resource,
|
||||
})),
|
||||
],
|
||||
[resources],
|
||||
);
|
||||
|
||||
const filteredPermissions = useMemo(() => {
|
||||
let filtered = observedList;
|
||||
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
filtered = filtered.filter((obs) =>
|
||||
obs.permission.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceFilter !== 'all') {
|
||||
filtered = filtered.filter((obs) => {
|
||||
const { object } = parsePermission(obs.permission);
|
||||
const resource = object.split(':')[0];
|
||||
return resource === resourceFilter;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered.map((obs) => obs.permission);
|
||||
}, [observedList, search, resourceFilter]);
|
||||
|
||||
const { groups, orderedPermissions } = useMemo(() => {
|
||||
const groupMap = new Map<string, BrandedPermission[]>();
|
||||
for (const permission of filteredPermissions) {
|
||||
const { object } = parsePermission(permission);
|
||||
const resource = object.split(':')[0] || 'other';
|
||||
const bucket = groupMap.get(resource);
|
||||
if (bucket) {
|
||||
bucket.push(permission);
|
||||
} else {
|
||||
groupMap.set(resource, [permission]);
|
||||
}
|
||||
}
|
||||
|
||||
const sortItems = (items: BrandedPermission[]): BrandedPermission[] =>
|
||||
[...items].sort((a, b) => {
|
||||
const objA = parsePermission(a).object;
|
||||
const objB = parsePermission(b).object;
|
||||
const idA = objA.split(':')[1] ?? '';
|
||||
const idB = objB.split(':')[1] ?? '';
|
||||
const isWildcardA = idA === '*';
|
||||
const isWildcardB = idB === '*';
|
||||
|
||||
// Wildcards first
|
||||
if (isWildcardA && !isWildcardB) {
|
||||
return -1;
|
||||
}
|
||||
if (!isWildcardA && isWildcardB) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Then by object ID, then by full permission
|
||||
const idCompare = idA.localeCompare(idB);
|
||||
if (idCompare !== 0) {
|
||||
return idCompare;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const sortedGroups = Array.from(groupMap, ([resource, items]) => ({
|
||||
resource,
|
||||
items: sortItems(items),
|
||||
})).sort((a, b) => a.resource.localeCompare(b.resource));
|
||||
return {
|
||||
groups: sortedGroups,
|
||||
orderedPermissions: sortedGroups.flatMap((group) => group.items),
|
||||
};
|
||||
}, [filteredPermissions]);
|
||||
|
||||
const indexByPermission = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
orderedPermissions.forEach((permission, index) => {
|
||||
map.set(permission, index);
|
||||
});
|
||||
return map;
|
||||
}, [orderedPermissions]);
|
||||
|
||||
const hasActiveFilter = search !== '' || resourceFilter !== 'all';
|
||||
|
||||
const filteredOverrideCount = useMemo(() => {
|
||||
if (!hasActiveFilter) {
|
||||
return Object.keys(overrides).length;
|
||||
}
|
||||
return filteredPermissions.filter((p) => p in overrides).length;
|
||||
}, [hasActiveFilter, overrides, filteredPermissions]);
|
||||
|
||||
const overrideCount = Object.keys(overrides).length;
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
resourceFilter,
|
||||
setResourceFilter,
|
||||
observedList,
|
||||
resourceFilterItems,
|
||||
filteredPermissions,
|
||||
groups,
|
||||
orderedPermissions,
|
||||
indexByPermission,
|
||||
hasActiveFilter,
|
||||
filteredOverrideCount,
|
||||
overrideCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
|
||||
import { OverrideState, OVERRIDE_CYCLE } from '../types';
|
||||
|
||||
type UseModalKeyboardOptions = {
|
||||
permissions: BrandedPermission[];
|
||||
overrides: Record<string, OverrideState>;
|
||||
onCycle: (permission: BrandedPermission) => void;
|
||||
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
|
||||
onClose: () => void;
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
};
|
||||
|
||||
type UseModalKeyboardResult = {
|
||||
selectedIndex: number;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
};
|
||||
|
||||
type KeyContext = {
|
||||
permissions: BrandedPermission[];
|
||||
overrides: Record<string, OverrideState>;
|
||||
selectedIndex: number;
|
||||
setSelectedIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
onCycle: (permission: BrandedPermission) => void;
|
||||
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
|
||||
};
|
||||
|
||||
const ARROW_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
|
||||
|
||||
const NUMBER_KEY_INDEX: Record<string, number> = {
|
||||
'1': 0,
|
||||
'2': 1,
|
||||
'3': 2,
|
||||
'4': 3,
|
||||
'5': 4,
|
||||
};
|
||||
|
||||
function stepOverrideState(
|
||||
current: OverrideState,
|
||||
direction: number,
|
||||
): OverrideState {
|
||||
const currentIndex = OVERRIDE_CYCLE.indexOf(current);
|
||||
const nextIndex =
|
||||
(currentIndex + direction + OVERRIDE_CYCLE.length) % OVERRIDE_CYCLE.length;
|
||||
return OVERRIDE_CYCLE[nextIndex];
|
||||
}
|
||||
|
||||
// Arrow keys stay active even while the search input is focused so the list can
|
||||
// be driven without leaving the search field.
|
||||
function handleArrowKey(key: string, ctx: KeyContext): void {
|
||||
if (key === 'ArrowDown') {
|
||||
ctx.setSelectedIndex((prev) =>
|
||||
Math.min(prev + 1, ctx.permissions.length - 1),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (key === 'ArrowUp') {
|
||||
ctx.setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||
return;
|
||||
}
|
||||
const selected = ctx.permissions[ctx.selectedIndex];
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const direction = key === 'ArrowLeft' ? -1 : 1;
|
||||
ctx.onSetOverride(
|
||||
selected,
|
||||
stepOverrideState(ctx.overrides[selected] ?? OverrideState.Reset, direction),
|
||||
);
|
||||
}
|
||||
|
||||
// Number and space/enter shortcuts type into the search field, so they only run
|
||||
// when it is not focused. Returns whether the key was handled.
|
||||
function handleActionKey(key: string, ctx: KeyContext): boolean {
|
||||
const selected = ctx.permissions[ctx.selectedIndex];
|
||||
const numberIndex = NUMBER_KEY_INDEX[key];
|
||||
if (numberIndex !== undefined) {
|
||||
if (selected) {
|
||||
ctx.onSetOverride(selected, OVERRIDE_CYCLE[numberIndex]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (key === ' ' || key === 'Enter') {
|
||||
if (selected) {
|
||||
ctx.onCycle(selected);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useModalKeyboard({
|
||||
permissions,
|
||||
overrides,
|
||||
onCycle,
|
||||
onSetOverride,
|
||||
onClose,
|
||||
searchInputRef,
|
||||
}: UseModalKeyboardOptions): UseModalKeyboardResult {
|
||||
// Start with no selection (-1) to avoid accidental override changes from
|
||||
// Enter keypress that opened the modal also triggering cycleOverride.
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const isSearchFocused = document.activeElement === searchInputRef.current;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '/') {
|
||||
if (!isSearchFocused) {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx: KeyContext = {
|
||||
permissions,
|
||||
overrides,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
onCycle,
|
||||
onSetOverride,
|
||||
};
|
||||
|
||||
if (ARROW_KEYS.has(e.key)) {
|
||||
e.preventDefault();
|
||||
handleArrowKey(e.key, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSearchFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleActionKey(e.key, ctx)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[
|
||||
permissions,
|
||||
overrides,
|
||||
selectedIndex,
|
||||
onCycle,
|
||||
onSetOverride,
|
||||
onClose,
|
||||
searchInputRef,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (selectedIndex >= permissions.length && permissions.length > 0) {
|
||||
setSelectedIndex(permissions.length - 1);
|
||||
}
|
||||
}, [permissions.length, selectedIndex]);
|
||||
|
||||
return {
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
};
|
||||
}
|
||||
46
frontend/src/lib/authz/devtools/types.ts
Normal file
46
frontend/src/lib/authz/devtools/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { BrandedPermission } from '../hooks/useAuthZ/types';
|
||||
|
||||
export enum OverrideState {
|
||||
Granted = 'granted',
|
||||
Denied = 'denied',
|
||||
Delay = 'delay',
|
||||
Error = 'error',
|
||||
Reset = 'reset',
|
||||
}
|
||||
|
||||
export type ObservedPermission = {
|
||||
permission: BrandedPermission;
|
||||
apiValue: boolean | null;
|
||||
lastSeen: number;
|
||||
};
|
||||
|
||||
export type PermissionOverride = {
|
||||
permission: BrandedPermission;
|
||||
state: OverrideState;
|
||||
};
|
||||
|
||||
export type AuthZDevStore = {
|
||||
isModalOpen: boolean;
|
||||
observed: Record<string, ObservedPermission>;
|
||||
overrides: Record<string, OverrideState>;
|
||||
|
||||
openModal: () => void;
|
||||
closeModal: () => void;
|
||||
toggleModal: () => void;
|
||||
|
||||
registerObserved: (permission: BrandedPermission, apiValue: boolean) => void;
|
||||
setOverride: (permission: BrandedPermission, state: OverrideState) => void;
|
||||
clearOverride: (permission: BrandedPermission) => void;
|
||||
clearAllOverrides: (permissions?: BrandedPermission[]) => void;
|
||||
grantAll: (permissions?: BrandedPermission[]) => void;
|
||||
denyAll: (permissions?: BrandedPermission[]) => void;
|
||||
cycleOverride: (permission: BrandedPermission) => void;
|
||||
};
|
||||
|
||||
export const OVERRIDE_CYCLE: OverrideState[] = [
|
||||
OverrideState.Reset,
|
||||
OverrideState.Granted,
|
||||
OverrideState.Denied,
|
||||
OverrideState.Delay,
|
||||
OverrideState.Error,
|
||||
];
|
||||
137
frontend/src/lib/authz/devtools/useAuthZDevStore.ts
Normal file
137
frontend/src/lib/authz/devtools/useAuthZDevStore.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import type { BrandedPermission } from '../hooks/useAuthZ/types';
|
||||
import { OverrideState, OVERRIDE_CYCLE, type AuthZDevStore } from './types';
|
||||
import { getScopedKey } from 'utils/storage';
|
||||
|
||||
export const useAuthZDevStore = create<AuthZDevStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
isModalOpen: false,
|
||||
observed: {},
|
||||
overrides: {},
|
||||
|
||||
openModal: (): void => {
|
||||
set({ isModalOpen: true });
|
||||
},
|
||||
|
||||
closeModal: (): void => {
|
||||
set({ isModalOpen: false });
|
||||
},
|
||||
|
||||
toggleModal: (): void => {
|
||||
set((state) => ({ isModalOpen: !state.isModalOpen }));
|
||||
},
|
||||
|
||||
registerObserved: (
|
||||
permission: BrandedPermission,
|
||||
apiValue: boolean,
|
||||
): void => {
|
||||
set((state) => ({
|
||||
observed: {
|
||||
...state.observed,
|
||||
[permission]: {
|
||||
permission,
|
||||
apiValue,
|
||||
lastSeen: Date.now(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setOverride: (permission: BrandedPermission, state: OverrideState): void => {
|
||||
if (state === OverrideState.Reset) {
|
||||
get().clearOverride(permission);
|
||||
return;
|
||||
}
|
||||
set((s) => ({
|
||||
overrides: {
|
||||
...s.overrides,
|
||||
[permission]: state,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
clearOverride: (permission: BrandedPermission): void => {
|
||||
set((state) => {
|
||||
const { [permission]: _, ...rest } = state.overrides;
|
||||
return { overrides: rest };
|
||||
});
|
||||
},
|
||||
|
||||
clearAllOverrides: (permissions?: BrandedPermission[]): void => {
|
||||
if (permissions) {
|
||||
set((state) => {
|
||||
const newOverrides = { ...state.overrides };
|
||||
for (const permission of permissions) {
|
||||
delete newOverrides[permission];
|
||||
}
|
||||
return { overrides: newOverrides };
|
||||
});
|
||||
} else {
|
||||
set({ overrides: {} });
|
||||
}
|
||||
},
|
||||
|
||||
grantAll: (permissions?: BrandedPermission[]): void => {
|
||||
set((state) => {
|
||||
const keys = permissions ?? Object.keys(state.observed);
|
||||
const newOverrides: Record<string, OverrideState> = {
|
||||
...state.overrides,
|
||||
};
|
||||
for (const key of keys) {
|
||||
newOverrides[key] = OverrideState.Granted;
|
||||
}
|
||||
return { overrides: newOverrides };
|
||||
});
|
||||
},
|
||||
|
||||
denyAll: (permissions?: BrandedPermission[]): void => {
|
||||
set((state) => {
|
||||
const keys = permissions ?? Object.keys(state.observed);
|
||||
const newOverrides: Record<string, OverrideState> = {
|
||||
...state.overrides,
|
||||
};
|
||||
for (const key of keys) {
|
||||
newOverrides[key] = OverrideState.Denied;
|
||||
}
|
||||
return { overrides: newOverrides };
|
||||
});
|
||||
},
|
||||
|
||||
cycleOverride: (permission: BrandedPermission): void => {
|
||||
const currentOverride = get().overrides[permission] ?? OverrideState.Reset;
|
||||
const currentIndex = OVERRIDE_CYCLE.indexOf(currentOverride);
|
||||
const nextIndex = (currentIndex + 1) % OVERRIDE_CYCLE.length;
|
||||
const nextState = OVERRIDE_CYCLE[nextIndex];
|
||||
get().setOverride(permission, nextState);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: `@signoz/${getScopedKey('authz-dev-overrides')}`,
|
||||
partialize: (state) => {
|
||||
// Clear apiValue for permissions without active override (auto mode)
|
||||
// since the API value can change between sessions
|
||||
const observed: typeof state.observed = {};
|
||||
for (const [key, obs] of Object.entries(state.observed)) {
|
||||
observed[key] = {
|
||||
...obs,
|
||||
apiValue: key in state.overrides ? obs.apiValue : null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
observed,
|
||||
overrides: state.overrides,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const openAuthZDevModal = (): void =>
|
||||
useAuthZDevStore.getState().openModal();
|
||||
export const closeAuthZDevModal = (): void =>
|
||||
useAuthZDevStore.getState().closeModal();
|
||||
export const toggleAuthZDevModal = (): void =>
|
||||
useAuthZDevStore.getState().toggleModal();
|
||||
28
frontend/src/lib/authz/devtools/useAuthZQueryInvalidation.ts
Normal file
28
frontend/src/lib/authz/devtools/useAuthZQueryInvalidation.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import type { OverrideState } from './types';
|
||||
|
||||
type Overrides = Record<string, OverrideState>;
|
||||
|
||||
export function useAuthZQueryInvalidation(overrides: Overrides): void {
|
||||
const queryClient = useQueryClient();
|
||||
const prevOverridesRef = useRef<Overrides>(overrides);
|
||||
|
||||
useEffect(() => {
|
||||
const prevOverrides = prevOverridesRef.current;
|
||||
prevOverridesRef.current = overrides;
|
||||
|
||||
const allKeys = new Set([
|
||||
...Object.keys(prevOverrides),
|
||||
...Object.keys(overrides),
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (prevOverrides[key] !== overrides[key]) {
|
||||
// Reset query to initial state and trigger refetch for active observers
|
||||
void queryClient.resetQueries(['authz', key]);
|
||||
}
|
||||
}
|
||||
}, [overrides, queryClient]);
|
||||
}
|
||||
@@ -89,5 +89,13 @@ export type UseAuthZResult = {
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
permissions: AuthZCheckResponse | null;
|
||||
/**
|
||||
* True if every check is granted. False while loading or on error.
|
||||
*/
|
||||
allowed: boolean;
|
||||
/**
|
||||
* Checks that resolved as not granted (empty while loading/error).
|
||||
*/
|
||||
deniedPermissions: BrandedPermission[];
|
||||
refetchPermissions: () => void;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
@@ -46,12 +47,16 @@ describe('useAuthZ', () => {
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.permissions).toBeNull();
|
||||
expect(result.current.allowed).toBe(false);
|
||||
expect(result.current.deniedPermissions).toStrictEqual([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.permissions).toStrictEqual(expectedResponse);
|
||||
expect(result.current.allowed).toBe(false);
|
||||
expect(result.current.deniedPermissions).toStrictEqual([permission2]);
|
||||
});
|
||||
|
||||
it('should return error and null permissions when API errors', async () => {
|
||||
@@ -73,6 +78,89 @@ describe('useAuthZ', () => {
|
||||
|
||||
expect(result.current.error).not.toBeNull();
|
||||
expect(result.current.permissions).toBeNull();
|
||||
expect(result.current.allowed).toBe(false);
|
||||
expect(result.current.deniedPermissions).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should set allowed to true when all permissions are granted', async () => {
|
||||
const permission1 = buildPermission('read', 'role:*');
|
||||
const permission2 = buildPermission('update', 'role:123');
|
||||
|
||||
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, [true, true])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.allowed).toBe(true);
|
||||
expect(result.current.deniedPermissions).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should collect all denied permissions when multiple are denied', async () => {
|
||||
const permission1 = buildPermission('read', 'role:*');
|
||||
const permission2 = buildPermission('update', 'role:123');
|
||||
|
||||
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, [false, false])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.allowed).toBe(false);
|
||||
expect(result.current.deniedPermissions).toStrictEqual([
|
||||
permission1,
|
||||
permission2,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not fetch when enabled is false', async () => {
|
||||
let requestCount = 0;
|
||||
const permission = buildPermission('read', 'role:*');
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount += 1;
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useAuthZ([permission], { enabled: false }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(0);
|
||||
expect(result.current.allowed).toBe(false);
|
||||
expect(result.current.permissions).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('should refetch when permissions array changes', async () => {
|
||||
@@ -474,3 +562,120 @@ describe('useAuthZ', () => {
|
||||
expect(result2.current.permissions).not.toHaveProperty(permission1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAuthZ cache invalidation', () => {
|
||||
it('should re-render with updated data when query is invalidated', async () => {
|
||||
const permission = buildPermission('read', 'role:*');
|
||||
|
||||
let requestCount = 0;
|
||||
let shouldGrant = true;
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount++;
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [shouldGrant])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const queryClient = useQueryClient();
|
||||
const authz = useAuthZ([permission]);
|
||||
return { authz, queryClient };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authz.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(1);
|
||||
expect(result.current.authz.allowed).toBe(true);
|
||||
expect(result.current.authz.permissions).toStrictEqual({
|
||||
[permission]: { isGranted: true },
|
||||
});
|
||||
|
||||
// Change server response and reset query (forces refetch)
|
||||
shouldGrant = false;
|
||||
|
||||
await act(async () => {
|
||||
await result.current.queryClient.resetQueries(['authz', permission]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authz.allowed).toBe(false);
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(result.current.authz.permissions).toStrictEqual({
|
||||
[permission]: { isGranted: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should re-render all components using the same permission when invalidated', async () => {
|
||||
const permission = buildPermission('update', 'role:123');
|
||||
|
||||
let requestCount = 0;
|
||||
let shouldGrant = true;
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount++;
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [shouldGrant])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// Two separate hooks using the same permission
|
||||
const { result: result1 } = renderHook(
|
||||
() => {
|
||||
const queryClient = useQueryClient();
|
||||
const authz = useAuthZ([permission]);
|
||||
return { authz, queryClient };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const { result: result2 } = renderHook(() => useAuthZ([permission]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result1.current.authz.isLoading).toBe(false);
|
||||
expect(result2.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Both should show granted, single batched request
|
||||
expect(requestCount).toBe(1);
|
||||
expect(result1.current.authz.allowed).toBe(true);
|
||||
expect(result2.current.allowed).toBe(true);
|
||||
|
||||
// Change server response and reset query (forces refetch)
|
||||
shouldGrant = false;
|
||||
|
||||
await act(async () => {
|
||||
await result1.current.queryClient.resetQueries(['authz', permission]);
|
||||
});
|
||||
|
||||
// Both hooks should update
|
||||
await waitFor(() => {
|
||||
expect(result1.current.authz.allowed).toBe(false);
|
||||
expect(result2.current.allowed).toBe(false);
|
||||
});
|
||||
|
||||
expect(result1.current.authz.permissions).toStrictEqual({
|
||||
[permission]: { isGranted: false },
|
||||
});
|
||||
expect(result2.current.permissions).toStrictEqual({
|
||||
[permission]: { isGranted: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { authzCheck } from 'api/generated/services/authz';
|
||||
import type {
|
||||
CoretypesObjectDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { IS_DEV, MODE } from 'lib/env';
|
||||
|
||||
import { AUTHZ_CACHE_TIME, SINGLE_FLIGHT_WAIT_TIME_MS } from './constants';
|
||||
import {
|
||||
import type {
|
||||
AuthZCheckResponse,
|
||||
BrandedPermission,
|
||||
UseAuthZOptions,
|
||||
@@ -17,6 +19,59 @@ import {
|
||||
gettableTransactionToPermission,
|
||||
permissionToTransactionDto,
|
||||
} from './utils';
|
||||
import { OverrideState } from '../../devtools/types';
|
||||
|
||||
let devStoreRef:
|
||||
| typeof import('../../devtools/useAuthZDevStore').useAuthZDevStore
|
||||
| null = null;
|
||||
|
||||
if (IS_DEV) {
|
||||
void import('../../devtools/useAuthZDevStore').then((mod) => {
|
||||
devStoreRef = mod.useAuthZDevStore;
|
||||
return mod;
|
||||
});
|
||||
}
|
||||
|
||||
const DEV_DELAY_MS = 2000;
|
||||
|
||||
function getDevOverride(permission: BrandedPermission): OverrideState | null {
|
||||
if (!IS_DEV || !devStoreRef) {
|
||||
return null;
|
||||
}
|
||||
return devStoreRef.getState().overrides[permission] ?? null;
|
||||
}
|
||||
|
||||
async function applyDevOverrideToQuery(
|
||||
permission: BrandedPermission,
|
||||
fetchFn: () => Promise<AuthZCheckResponse>,
|
||||
): Promise<AuthZCheckResponse> {
|
||||
const override = getDevOverride(permission);
|
||||
|
||||
if (override === OverrideState.Error) {
|
||||
throw new Error(`[AuthZ DevTools] Simulated error for: ${permission}`);
|
||||
}
|
||||
|
||||
if (override === OverrideState.Delay) {
|
||||
await new Promise((resolve) => setTimeout(resolve, DEV_DELAY_MS));
|
||||
}
|
||||
|
||||
const response = await fetchFn();
|
||||
|
||||
if (IS_DEV && devStoreRef) {
|
||||
const apiValue = response[permission]?.isGranted ?? false;
|
||||
devStoreRef.getState().registerObserved(permission, apiValue);
|
||||
}
|
||||
|
||||
if (override === OverrideState.Granted) {
|
||||
return { [permission]: { isGranted: true } };
|
||||
}
|
||||
|
||||
if (override === OverrideState.Denied) {
|
||||
return { [permission]: { isGranted: false } };
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
let ctx: Promise<AuthZCheckResponse> | null;
|
||||
let pendingPermissions: BrandedPermission[] = [];
|
||||
@@ -27,10 +82,11 @@ function dispatchPermission(
|
||||
pendingPermissions.push(permission);
|
||||
|
||||
if (!ctx) {
|
||||
let resolve: (v: AuthZCheckResponse) => void, reject: (reason?: any) => void;
|
||||
ctx = new Promise<AuthZCheckResponse>((r, re) => {
|
||||
resolve = r;
|
||||
reject = re;
|
||||
let promiseResolve: (v: AuthZCheckResponse) => void,
|
||||
promiseReject: (reason?: unknown) => void;
|
||||
ctx = new Promise<AuthZCheckResponse>((resolve, reject) => {
|
||||
promiseResolve = resolve;
|
||||
promiseReject = reject;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -38,7 +94,9 @@ function dispatchPermission(
|
||||
pendingPermissions = [];
|
||||
ctx = null;
|
||||
|
||||
fetchManyPermissions(copiedPermissions).then(resolve).catch(reject);
|
||||
fetchManyPermissions(copiedPermissions)
|
||||
.then(promiseResolve)
|
||||
.catch(promiseReject);
|
||||
}, SINGLE_FLIGHT_WAIT_TIME_MS);
|
||||
}
|
||||
|
||||
@@ -85,19 +143,50 @@ export function useAuthZ(
|
||||
return {
|
||||
queryKey: ['authz', permission],
|
||||
cacheTime: AUTHZ_CACHE_TIME,
|
||||
staleTime: AUTHZ_CACHE_TIME,
|
||||
// Keep errored state in cache instead of refetching when new observers subscribe
|
||||
retryOnMount: false,
|
||||
// Only override retry in non-test mode to avoid interfering with test-utils QueryClient defaults
|
||||
...(MODE !== 'test' && {
|
||||
retry: (failureCount: number, error: unknown): boolean => {
|
||||
// Don't retry simulated dev errors - they will always fail
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes('[AuthZ DevTools]')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Don't retry server errors (5xx) - they won't recover
|
||||
if (
|
||||
isAxiosError(error) &&
|
||||
error.response?.status &&
|
||||
error.response.status >= 500
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 3;
|
||||
},
|
||||
}),
|
||||
refetchOnMount: false,
|
||||
refetchIntervalInBackground: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
enabled,
|
||||
queryFn: async (): Promise<AuthZCheckResponse> => {
|
||||
const response = await dispatchPermission(permission);
|
||||
|
||||
return {
|
||||
[permission]: {
|
||||
isGranted: response[permission].isGranted,
|
||||
},
|
||||
const fetchFn = async (): Promise<AuthZCheckResponse> => {
|
||||
const response = await dispatchPermission(permission);
|
||||
return {
|
||||
[permission]: {
|
||||
isGranted: response[permission].isGranted,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
if (IS_DEV) {
|
||||
return applyDevOverrideToQuery(permission, fetchFn);
|
||||
}
|
||||
|
||||
return fetchFn();
|
||||
},
|
||||
};
|
||||
}),
|
||||
@@ -107,6 +196,7 @@ export function useAuthZ(
|
||||
() => queryResults.some((q) => q.isLoading),
|
||||
[queryResults],
|
||||
);
|
||||
|
||||
const isFetching = useMemo(
|
||||
() => queryResults.some((q) => q.isFetching),
|
||||
[queryResults],
|
||||
@@ -139,15 +229,31 @@ export function useAuthZ(
|
||||
|
||||
const refetchPermissions = useCallback(() => {
|
||||
for (const query of queryResults) {
|
||||
query.refetch();
|
||||
void query.refetch();
|
||||
}
|
||||
}, [queryResults]);
|
||||
|
||||
const allowed = useMemo(() => {
|
||||
if (isLoading || error || !data) {
|
||||
return false;
|
||||
}
|
||||
return permissions.every((check) => data[check]?.isGranted === true);
|
||||
}, [permissions, data, isLoading, error]);
|
||||
|
||||
const deniedPermissions = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
return permissions.filter((check) => data[check]?.isGranted !== true);
|
||||
}, [permissions, data]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
permissions: data ?? null,
|
||||
allowed,
|
||||
deniedPermissions,
|
||||
refetchPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user