mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-15 16:30:27 +01:00
Compare commits
9 Commits
testing-fe
...
feat/cloud
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d4a9b183e | ||
|
|
c5fdc4c3d1 | ||
|
|
afcf2c6053 | ||
|
|
ec59fecdda | ||
|
|
5a762c678e | ||
|
|
55b1311f78 | ||
|
|
59668698a2 | ||
|
|
22ed687d44 | ||
|
|
1da016cf1a |
56
.github/workflows/jsci.yaml
vendored
56
.github/workflows/jsci.yaml
vendored
@@ -61,59 +61,3 @@ jobs:
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
JS_SRC: frontend
|
||||
md-languages:
|
||||
if: |
|
||||
(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: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: validate md languages
|
||||
run: bash frontend/scripts/validate-md-languages.sh
|
||||
authz:
|
||||
if: |
|
||||
(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: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
yarn install
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Install Python dependencies
|
||||
working-directory: ./tests/integration
|
||||
run: |
|
||||
uv sync
|
||||
|
||||
- name: Start test environment
|
||||
run: |
|
||||
make py-test-setup
|
||||
|
||||
- name: Generate permissions.type.ts
|
||||
run: |
|
||||
node frontend/scripts/generate-permissions-type.js
|
||||
|
||||
- name: Teardown test environment
|
||||
if: always()
|
||||
run: |
|
||||
make py-test-teardown
|
||||
|
||||
- name: Check for changes
|
||||
run: |
|
||||
if ! git diff --exit-code frontend/src/hooks/useAuthZ/permissions.type.ts; then
|
||||
echo "::error::frontend/src/hooks/useAuthZ/permissions.type.ts is out of date. Please run the generator locally and commit the changes: npm run generate:permissions-type (from the frontend directory)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1763,134 +1763,6 @@ components:
|
||||
- type
|
||||
- orgId
|
||||
type: object
|
||||
ServiceaccounttypesFactorAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
expires_at:
|
||||
minimum: 0
|
||||
type: integer
|
||||
id:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
last_used:
|
||||
format: date-time
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
service_account_id:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- key
|
||||
- expires_at
|
||||
- last_used
|
||||
- service_account_id
|
||||
type: object
|
||||
ServiceaccounttypesGettableFactorAPIKeyWithKey:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- key
|
||||
type: object
|
||||
ServiceaccounttypesPostableFactorAPIKey:
|
||||
properties:
|
||||
expires_at:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- expires_at
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccount:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- name
|
||||
- email
|
||||
- roles
|
||||
type: object
|
||||
ServiceaccounttypesServiceAccount:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgID:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- email
|
||||
- roles
|
||||
- status
|
||||
- orgID
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableFactorAPIKey:
|
||||
properties:
|
||||
expires_at:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- expires_at
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableServiceAccount:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- name
|
||||
- email
|
||||
- roles
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableServiceAccountStatus:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
type: object
|
||||
TelemetrytypesFieldContext:
|
||||
enum:
|
||||
- metric
|
||||
@@ -4665,586 +4537,6 @@ paths:
|
||||
summary: Patch objects for a role by relation
|
||||
tags:
|
||||
- role
|
||||
/api/v1/service_accounts:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists the service accounts for an organisation
|
||||
operationId: ListServiceAccounts
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccount'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: List service accounts
|
||||
tags:
|
||||
- serviceaccount
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a service account
|
||||
operationId: CreateServiceAccount
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
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
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create service account
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint deletes an existing service account
|
||||
operationId: DeleteServiceAccount
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"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: Deletes a service account
|
||||
tags:
|
||||
- serviceaccount
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets an existing service account
|
||||
operationId: GetServiceAccount
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccount'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"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: Gets a service account
|
||||
tags:
|
||||
- serviceaccount
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates an existing service account
|
||||
operationId: UpdateServiceAccount
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesUpdatableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
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: Updates a service account
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}/keys:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists the service account keys
|
||||
operationId: ListServiceAccountKeys
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesFactorAPIKey'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: List service account keys
|
||||
tags:
|
||||
- serviceaccount
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a service account key
|
||||
operationId: CreateServiceAccountKey
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableFactorAPIKey'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesGettableFactorAPIKeyWithKey'
|
||||
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
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create a service account key
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}/keys/{fid}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint revokes an existing service account key
|
||||
operationId: RevokeServiceAccountKey
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: fid
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"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: Revoke a service account key
|
||||
tags:
|
||||
- serviceaccount
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates an existing service account key
|
||||
operationId: UpdateServiceAccountKey
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: fid
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesUpdatableFactorAPIKey'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
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: Updates a service account key
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}/status:
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates an existing service account status
|
||||
operationId: UpdateServiceAccountStatus
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesUpdatableServiceAccountStatus'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
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: Updates a service account status
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/user:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -6167,10 +5459,6 @@ paths:
|
||||
name: searchText
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: source
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
# Abstractions
|
||||
|
||||
This document provides rules for deciding when a new type, interface, or intermediate representation is warranted in Go code. The goal is to keep the codebase navigable by ensuring every abstraction earns its place.
|
||||
|
||||
## The cost of a new abstraction
|
||||
|
||||
Every exported type, interface, or wrapper is a permanent commitment. It must be named, documented, tested, and understood by every future contributor. It creates a new concept in the codebase vocabulary. Before introducing one, verify that the cost is justified by a concrete benefit that cannot be achieved with existing mechanisms.
|
||||
|
||||
## Before you introduce anything new
|
||||
|
||||
Answer these four questions. If writing a PR, include the answers in the description.
|
||||
|
||||
1. **What already exists?** Name the specific type, function, interface, or library that covers this ground today.
|
||||
2. **What does the new abstraction add?** Name the concrete operation, guarantee, or capability. "Cleaner" or "more reusable" are not sufficient; name what the caller can do that it could not do before.
|
||||
3. **What does the new abstraction drop?** If it wraps or mirrors an existing structure, list what it cannot represent. Every gap must be either justified or handled with an explicit error.
|
||||
4. **Who consumes it?** List the call sites. If there is only one producer and one consumer in the same call chain, you likely need a function, not a type.
|
||||
|
||||
## Rules
|
||||
|
||||
### 1. Prefer functions over types
|
||||
|
||||
If a piece of logic has one input and one output, write a function. Do not create a struct to hold intermediate state that is built in one place and read in one place. A function is easier to test, easier to inline, and does not expand the vocabulary of the codebase.
|
||||
|
||||
```go
|
||||
// Prefer this:
|
||||
func ConvertConfig(src ExternalConfig) (InternalConfig, error)
|
||||
|
||||
// Over this:
|
||||
type ConfigAdapter struct { ... }
|
||||
func NewConfigAdapter(src ExternalConfig) *ConfigAdapter
|
||||
func (a *ConfigAdapter) ToInternal() (InternalConfig, error)
|
||||
```
|
||||
|
||||
The two-step version is only justified when `ConfigAdapter` has multiple distinct consumers that use it in different ways.
|
||||
|
||||
### 2. Do not duplicate structures you do not own
|
||||
|
||||
When a library or external package produces a structured output, operate on that output directly. Do not create a parallel type that mirrors a subset of its fields.
|
||||
|
||||
A partial copy will:
|
||||
- **Silently lose data** when the source has fields or variants the copy does not account for.
|
||||
- **Drift** when the source evolves and the copy is not updated in lockstep.
|
||||
- **Add a conversion step** that doubles the code surface and the opportunity for bugs.
|
||||
|
||||
If you need to shield consumers from a dependency, define a narrow interface over the dependency's type rather than copying its shape into a new struct.
|
||||
|
||||
### 3. Never silently discard input
|
||||
|
||||
If your code receives structured input and cannot handle part of it, return an error. Do not silently return nil, skip the element, or produce a partial result. Silent data loss is the hardest class of bug to detect because the code appears to work, it just produces wrong results.
|
||||
|
||||
```go
|
||||
// Wrong: silently ignores the unrecognized case.
|
||||
default:
|
||||
return nil
|
||||
|
||||
// Right: makes the gap visible.
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported %T value: %v", v, v)
|
||||
```
|
||||
|
||||
This applies broadly: type switches, format conversions, data migrations, enum mappings, configuration parsing. Anywhere a `default` or `else` branch can swallow input, it should surface an error instead.
|
||||
|
||||
### 4. Do not expose methods that lose information
|
||||
|
||||
A method on a structured type should not strip meaning from the structure it belongs to. If a caller needs to iterate over elements for a specific purpose (validation, aggregation, logging), write that logic as a standalone function that operates on the structure with full context, rather than adding a method that returns a reduced view.
|
||||
|
||||
```go
|
||||
// Problematic: callers cannot distinguish how items were related.
|
||||
func (o *Order) AllLineItems() []LineItem { ... }
|
||||
|
||||
// Better: the validation logic operates on the full structure.
|
||||
func ValidateOrder(o *Order) error { ... }
|
||||
```
|
||||
|
||||
Public methods shape how a type is used. Once a lossy accessor exists, callers will depend on it, and the lost information becomes unrecoverable at those call sites.
|
||||
|
||||
### 5. Interfaces should be discovered, not predicted
|
||||
|
||||
Do not define an interface before you have at least two concrete implementations that need it. An interface with one implementation is not abstraction; it is indirection that makes it harder to navigate from call site to implementation.
|
||||
|
||||
The exception is interfaces required for testing (e.g., for mocking an external dependency). In that case, define the interface in the **consuming** package, not the providing package, following the Go convention of [accepting interfaces and returning structs](https://go.dev/wiki/CodeReviewComments#interfaces).
|
||||
|
||||
## When a new type IS warranted
|
||||
|
||||
See [Types](types.md#when-a-new-type-is-warranted) for the criteria that justify introducing a new type.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- A function is almost always simpler than a type. Start with a function; promote to a type only when you have evidence of need.
|
||||
- Never silently drop data. If you cannot handle it, error.
|
||||
- If your new type mirrors an existing one, you need a strong reason beyond "nicer to work with".
|
||||
- If your type has one producer and one consumer, it is indirection, not abstraction.
|
||||
- Interfaces come from need (multiple implementations), not from prediction.
|
||||
- When in doubt, do not add it. It is easier to add an abstraction later when the need is clear than to remove one after it has spread through the codebase.
|
||||
|
||||
## Further reading
|
||||
|
||||
These works and our own lessions shaped the above guidelines
|
||||
|
||||
- [The Wrong Abstraction](https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction) - Sandi Metz. The wrong abstraction is worse than duplication. If you find yourself passing parameters and adding conditional paths through shared code, inline it back into every caller and let the duplication show you what the right abstraction is.
|
||||
- [Write code that is easy to delete, not easy to extend](https://programmingisterrible.com/post/139222674273/write-code-that-is-easy-to-delete-not-easy-to) - tef. Every abstraction is a bet on the future. Optimize for how cheaply you can remove code when the bet is wrong, not for how easily you can extend it when the bet is right.
|
||||
- [Goodbye, Clean Code](https://overreacted.io/goodbye-clean-code/) - Dan Abramov. A refactoring that removes duplication can look cleaner while making the code harder to change. Clean-looking and easy-to-change are not the same thing.
|
||||
- [A Philosophy of Software Design](https://www.amazon.com/Philosophy-Software-Design-John-Ousterhout/dp/1732102201) - John Ousterhout. Good abstractions are deep: simple interface, complex implementation. A "false abstraction" omits important details while appearing simple, and is worse than no abstraction at all. ([Summary by Pragmatic Engineer](https://blog.pragmaticengineer.com/a-philosophy-of-software-design-review/))
|
||||
- [Simplicity is Complicated](https://go.dev/talks/2015/simplicity-is-complicated.slide) - Rob Pike. Go-specific. Fewer orthogonal concepts that compose predictably beat many overlapping ones. Features were left out of Go deliberately; the same discipline applies to your own code.
|
||||
@@ -49,43 +49,6 @@ Follow these rules:
|
||||
|
||||
5. **Test files stay alongside source**: Unit tests go in `_test.go` files next to the code they test, in the same package.
|
||||
|
||||
## How should I order code within a file?
|
||||
|
||||
Within a single `.go` file, declarations should follow this order:
|
||||
|
||||
1. Constants
|
||||
2. Variables
|
||||
3. Types (structs, interfaces)
|
||||
4. Constructor functions (`New...`)
|
||||
5. Exported methods and functions
|
||||
6. Unexported methods and functions
|
||||
|
||||
```go
|
||||
// 1. Constants
|
||||
const defaultTimeout = 30 * time.Second
|
||||
|
||||
// 2. Variables
|
||||
var ErrNotFound = errors.New(errors.TypeNotFound, errors.CodeNotFound, "resource not found")
|
||||
|
||||
// 3. Types
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// 4. Constructors
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// 5. Exported methods
|
||||
func (s *Store) Get(ctx context.Context, id string) (*Resource, error) { ... }
|
||||
|
||||
// 6. Unexported methods
|
||||
func (s *Store) buildQuery(id string) string { ... }
|
||||
```
|
||||
|
||||
This ordering makes files predictable. A reader scanning from top to bottom sees the contract (constants, types, constructors) before the implementation (methods), and exported behavior before internal helpers.
|
||||
|
||||
## How should I name symbols?
|
||||
|
||||
### Exported symbols
|
||||
@@ -127,7 +90,9 @@ Never introduce circular imports. If package A needs package B and B needs A, ex
|
||||
|
||||
## Where do shared types go?
|
||||
|
||||
See [Types](types.md) for full conventions on type placement, naming variants, composition, and constructors.
|
||||
Most types belong in `pkg/types/` under a domain-specific sub-package (e.g., `pkg/types/ruletypes`, `pkg/types/authtypes`).
|
||||
|
||||
Do not put domain logic in `pkg/types/`. Only data structures, constants, and simple methods.
|
||||
|
||||
## How do I merge or move packages?
|
||||
|
||||
@@ -140,10 +105,6 @@ When two packages are tightly coupled (one imports the other's constants, they c
|
||||
5. Delete the old packages. Do not leave behind re-export shims.
|
||||
6. Verify with `go build ./...`, `go test ./<new-pkg>/...`, and `go vet ./...`.
|
||||
|
||||
## When should I use valuer types?
|
||||
|
||||
See [Types](types.md#typed-domain-values-pkgvaluer) for valuer types, when to use them, and the enum pattern using `valuer.String`.
|
||||
|
||||
## When should I add documentation?
|
||||
|
||||
Add a `doc.go` with a package-level comment for any package that is non-trivial or has multiple consumers. Keep it to 1–3 sentences:
|
||||
@@ -158,10 +119,6 @@ package cache
|
||||
|
||||
- Package names are domain-specific and lowercase. Never generic names like `util` or `common`.
|
||||
- The file matching the package name (e.g., `cache.go`) defines the public interface. Implementation details go elsewhere.
|
||||
- Within a file, order declarations: constants, variables, types, constructors, exported functions, unexported functions.
|
||||
- Segregate types across files by responsibility. A file with 5 unrelated types is harder to navigate than 5 files with one type each.
|
||||
- Use valuer types (`valuer.String`, `valuer.Email`, `valuer.UUID`, `valuer.TextDuration`) for domain values that need validation, normalization, or cross-boundary serialization. See [Types](types.md#typed-domain-values-pkgvaluer) for details.
|
||||
- Avoid `init()` functions. If you need to initialize a variable, use a package-level `var` with a function call or a `sync.Once`. `init()` hides execution order, makes testing harder, and has caused subtle bugs in large codebases.
|
||||
- Never introduce circular imports. Extract shared types into `pkg/types/` when needed.
|
||||
- Watch for symbol name collisions when merging packages, prefix to disambiguate.
|
||||
- Put test helpers in a `{pkg}test/` sub-package, not in the main package.
|
||||
|
||||
@@ -8,41 +8,13 @@ We adhere to three primary style guides as our foundation:
|
||||
- [Code Review Comments](https://go.dev/wiki/CodeReviewComments) - For understanding common comments in code reviews
|
||||
- [Google Style Guide](https://google.github.io/styleguide/go/) - Additional practices from Google
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
**Discover before inventing.** Before writing new code, search the codebase for existing solutions. SigNoz has established patterns for common problems: `pkg/valuer` for typed domain values, `pkg/errors` for structured errors, `pkg/factory` for provider wiring, `{pkg}test/` sub-packages for test helpers, and shared fixtures for integration tests. Duplicating what already exists creates drift and maintenance burden. When you find an existing pattern, use it. When you don't find one, check with the maintainers before building your own.
|
||||
|
||||
## How to approach a feature
|
||||
|
||||
Building a feature is not one task, it is a sequence of concerns that build on each other. Work through them in this order:
|
||||
|
||||
1. **Domain design (types).** Define the types that represent your domain. What are the entities, what are their relationships, what are the constraints? This is where you decide your data model. Get this right first because everything else depends on it. See [Packages](packages.md) and [Abstractions](abstractions.md).
|
||||
|
||||
2. **Structure (services / modules / handlers).** Place your code in the right layer given the current infrastructure. If the current structure does not work for your feature, that is the time to open a discussion and write a technical document, not to silently reshape things in the same PR. See [Handler](handler.md) and [Provider](provider.md).
|
||||
|
||||
3. **HTTP endpoints (paths, status codes, errors).** Pay close attention to detail here. Paths, methods, request/response shapes, status codes, error codes. These are the contract with consumers and are expensive to change after release. See [Endpoint](endpoint.md) and [Handler](handler.md).
|
||||
|
||||
4. **Database constraints (org_id, foreign keys, migrations).** Ensure org scoping, schema consistency, and migration correctness. See [SQL](sql.md).
|
||||
|
||||
5. **Business logic (module layer).** With the types, structure, endpoints, and storage in place, the focus narrows to the actual logic. This is where review should concentrate on correctness, edge cases, and error handling.
|
||||
|
||||
This ordering also gives you a natural way to split PRs. Each layer affects a different area and requires a different lens for review. A PR that mixes refactoring with new feature logic is hard to review and risky to ship. Separate them.
|
||||
|
||||
For large refactors or features that touch multiple subsystems, write a short technical document outlining the design and get relevant stakeholders aligned before starting implementation. This saves significant back-and-forth during review.
|
||||
|
||||
## Area-specific guides
|
||||
|
||||
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
|
||||
- [Errors](errors.md) - Structured error handling
|
||||
- [Endpoint](endpoint.md) - HTTP endpoint patterns
|
||||
- [Flagger](flagger.md) - Feature flag patterns
|
||||
- [Handler](handler.md) - HTTP handler patterns
|
||||
- [Integration](integration.md) - Integration testing
|
||||
- [Provider](provider.md) - Dependency injection and provider patterns
|
||||
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
|
||||
- [Service](service.md) - Managed service lifecycle with `factory.Service`
|
||||
- [SQL](sql.md) - Database and SQL patterns
|
||||
- [Testing](testing.md) - Writing tests that catch bugs without becoming a maintenance burden
|
||||
- [Types](types.md) - Type placement, naming variants, composition, and constructors
|
||||
- [Packages](packages.md) — Naming, layout, and conventions for `pkg/` packages
|
||||
- [Errors](errors.md) — Structured error handling
|
||||
- [Handler](handler.md) — Writing HTTP handlers and OpenAPI integration
|
||||
- [Endpoint](endpoint.md) — Endpoint conventions
|
||||
- [SQL](sql.md) — Database query patterns
|
||||
- [Provider](provider.md) — Provider pattern
|
||||
- [Integration](integration.md) — Integration conventions
|
||||
- [Flagger](flagger.md) — Feature flag conventions
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
# Service
|
||||
|
||||
A service is a component with a managed lifecycle: it starts, runs for the lifetime of the application, and stops gracefully.
|
||||
|
||||
Services are distinct from [providers](provider.md). A provider adapts an external dependency behind an interface. A service has a managed lifecycle that is tied to the lifetime of the application.
|
||||
|
||||
## When do you need a service?
|
||||
|
||||
You need a service when your component needs to do work that outlives a single method call:
|
||||
|
||||
- **Periodic work**: polling an external system, garbage-collecting expired data, syncing state on an interval.
|
||||
- **Graceful shutdown**: holding resources (connections, caches, buffers) that must be flushed or closed before the process exits.
|
||||
- **Blocking on readiness**: waiting for an external dependency to become available before the application can proceed.
|
||||
|
||||
If your component only responds to calls and holds no state that requires cleanup, it is a provider, not a service. If it does both (responds to calls *and* needs a lifecycle), embed `factory.Service` in the provider interface; see [How to create a service](#how-to-create-a-service).
|
||||
|
||||
## The interface
|
||||
|
||||
The `factory.Service` interface in `pkg/factory/service.go` defines two methods:
|
||||
|
||||
```go
|
||||
type Service interface {
|
||||
// Starts a service. It should block and should not return until the service is stopped or it fails.
|
||||
Start(context.Context) error
|
||||
// Stops a service.
|
||||
Stop(context.Context) error
|
||||
}
|
||||
```
|
||||
|
||||
`Start` **must block**. It should not return until the service is stopped (returning `nil`) or something goes wrong (returning an error). If `Start` returns an error, the entire application shuts down.
|
||||
|
||||
`Stop` should cause `Start` to unblock and return. It must be safe to call from a different goroutine than the one running `Start`.
|
||||
|
||||
## Shutdown coordination
|
||||
|
||||
Every service uses a `stopC chan struct{}` to coordinate shutdown:
|
||||
|
||||
- **Constructor**: `stopC: make(chan struct{})`
|
||||
- **Start**: blocks on `<-stopC` (or uses it in a `select` loop)
|
||||
- **Stop**: `close(stopC)` to unblock `Start`
|
||||
|
||||
This is the standard pattern. Do not use `context.WithCancel` or other mechanisms for service-level shutdown coordination. See the examples in the next section.
|
||||
|
||||
## Service shapes
|
||||
|
||||
Two shapes recur across the codebase (these are not exhaustive, if a new shape is needed, bring it up for discussion before going ahead with the implementation), implemented by convention rather than base classes.
|
||||
|
||||
### Idle service
|
||||
|
||||
The service does work during startup or shutdown but has nothing to do while running. `Start` blocks on `<-stopC`. `Stop` closes `stopC` and optionally does cleanup.
|
||||
|
||||
The JWT tokenizer (`pkg/tokenizer/jwttokenizer/provider.go`) is a good example. It validates and creates tokens on demand via method calls, but has no periodic work to do. It still needs the service lifecycle so the registry can manage its lifetime:
|
||||
|
||||
```go
|
||||
// pkg/tokenizer/jwttokenizer/provider.go
|
||||
|
||||
func (provider *provider) Start(ctx context.Context) error {
|
||||
<-provider.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Stop(ctx context.Context) error {
|
||||
close(provider.stopC)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
The instrumentation SDK (`pkg/instrumentation/sdk.go`) is idle while running but does real cleanup in `Stop` shutting down its OpenTelemetry tracer and meter providers:
|
||||
|
||||
```go
|
||||
// pkg/instrumentation/sdk.go
|
||||
|
||||
func (i *SDK) Start(ctx context.Context) error {
|
||||
<-i.startCh
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *SDK) Stop(ctx context.Context) error {
|
||||
close(i.startCh)
|
||||
return errors.Join(
|
||||
i.sdk.Shutdown(ctx),
|
||||
i.meterProviderShutdownFunc(ctx),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Scheduled service
|
||||
|
||||
The service runs an operation repeatedly on a fixed interval. `Start` runs a ticker loop with a `select` on `stopC` and the ticker channel.
|
||||
|
||||
The opaque tokenizer (`pkg/tokenizer/opaquetokenizer/provider.go`) garbage-collects expired tokens and flushes cached last-observed-at timestamps to the database on a configurable interval:
|
||||
|
||||
```go
|
||||
// pkg/tokenizer/opaquetokenizer/provider.go
|
||||
|
||||
func (provider *provider) Start(ctx context.Context) error {
|
||||
ticker := time.NewTicker(provider.config.Opaque.GC.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-provider.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to get orgs data", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := provider.gc(ctx, org); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to garbage collect tokens", "error", err, "org_id", org.ID)
|
||||
}
|
||||
|
||||
if err := provider.flushLastObservedAt(ctx, org); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to flush tokens", "error", err, "org_id", org.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Its `Stop` does a final gc and flush before returning, so no data is lost on shutdown:
|
||||
|
||||
```go
|
||||
// pkg/tokenizer/opaquetokenizer/provider.go
|
||||
|
||||
func (provider *provider) Stop(ctx context.Context) error {
|
||||
close(provider.stopC)
|
||||
|
||||
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := provider.gc(ctx, org); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to garbage collect tokens", "error", err, "org_id", org.ID)
|
||||
}
|
||||
|
||||
if err := provider.flushLastObservedAt(ctx, org); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to flush tokens", "error", err, "org_id", org.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
The key points:
|
||||
|
||||
- In the loop, `select` on `stopC` and the ticker. Errors in iterations are logged but do not cause the service to return (which would shut down the application).
|
||||
- Only return an error from `Start` if the failure is unrecoverable.
|
||||
- Use `Stop` to flush or drain any in-memory state before the process exits.
|
||||
|
||||
## How to create a service
|
||||
|
||||
There are two cases: a standalone service and a provider that is also a service.
|
||||
|
||||
### Standalone service
|
||||
|
||||
A standalone service only has the `factory.Service` lifecycle i.e it does not serve as a dependency for other packages. The user reconciliation service is an example.
|
||||
|
||||
1. Define the service interface in your package. Embed `factory.Service`:
|
||||
|
||||
```go
|
||||
// pkg/modules/user/service.go
|
||||
package user
|
||||
|
||||
type Service interface {
|
||||
factory.Service
|
||||
}
|
||||
```
|
||||
|
||||
2. Create the implementation in an `impl` sub-package. Use an unexported struct with an exported constructor that returns the interface:
|
||||
|
||||
```go
|
||||
// pkg/modules/user/impluser/service.go
|
||||
package impluser
|
||||
|
||||
type service struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
// ... dependencies ...
|
||||
stopC chan struct{}
|
||||
}
|
||||
|
||||
func NewService(
|
||||
providerSettings factory.ProviderSettings,
|
||||
// ... dependencies ...
|
||||
) user.Service {
|
||||
return &service{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "go.signoz.io/pkg/modules/user"),
|
||||
// ... dependencies ...
|
||||
stopC: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Start(ctx context.Context) error { ... }
|
||||
func (s *service) Stop(ctx context.Context) error { ... }
|
||||
```
|
||||
|
||||
### Provider that is also a service
|
||||
|
||||
Many providers need a managed lifecycle: they poll, sync, or garbage-collect in the background. In this case, embed `factory.Service` in the provider interface. The implementation satisfies both the provider methods and `Start`/`Stop`.
|
||||
|
||||
```go
|
||||
// pkg/tokenizer/tokenizer.go
|
||||
package tokenizer
|
||||
|
||||
type Tokenizer interface {
|
||||
factory.Service
|
||||
CreateToken(context.Context, *authtypes.Identity, map[string]string) (*authtypes.Token, error)
|
||||
GetIdentity(context.Context, string) (*authtypes.Identity, error)
|
||||
// ... other methods ...
|
||||
}
|
||||
```
|
||||
|
||||
The implementation (e.g. `pkg/tokenizer/opaquetokenizer/provider.go`) implements `Start`, `Stop`, and all the provider methods on the same struct. See the [provider guide](provider.md) for how to set up the factory, config, and constructor. The `stopC` channel and `Start`/`Stop` methods follow the same patterns described above.
|
||||
|
||||
## How to wire it up
|
||||
|
||||
Wiring happens in `pkg/signoz/signoz.go`.
|
||||
|
||||
### 1. Instantiate the service
|
||||
|
||||
For a standalone service, call the constructor directly:
|
||||
|
||||
```go
|
||||
userService := impluser.NewService(providerSettings, store, module, orgGetter, authz, config.User.Root)
|
||||
```
|
||||
|
||||
For a provider that is also a service, use `factory.NewProviderFromNamedMap` as described in the [provider guide](provider.md). The returned value already implements `factory.Service`.
|
||||
|
||||
### 2. Register in the registry
|
||||
|
||||
Wrap the service with `factory.NewNamedService` and pass it to `factory.NewRegistry`:
|
||||
|
||||
```go
|
||||
registry, err := factory.NewRegistry(
|
||||
instrumentation.Logger(),
|
||||
// ... other services ...
|
||||
factory.NewNamedService(factory.MustNewName("user"), userService),
|
||||
)
|
||||
```
|
||||
|
||||
The name must be unique across all services. The registry handles the rest:
|
||||
|
||||
- **Start**: launches all services concurrently in goroutines.
|
||||
- **Wait**: blocks until a service returns an error, the context is cancelled, or a SIGINT/SIGTERM is received. Any service error triggers application shutdown.
|
||||
- **Stop**: stops all services concurrently, collects errors via `errors.Join`.
|
||||
|
||||
You do not call `Start` or `Stop` on individual services. The registry does it.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- A service has a managed lifecycle: `Start` blocks, `Stop` unblocks it.
|
||||
- Use `stopC chan struct{}` for shutdown coordination. `close(stopC)` in `Stop`, `<-stopC` in `Start`.
|
||||
- Service shapes: idle (block on `stopC`) and scheduled (ticker loop with `select`).
|
||||
- Unexported struct, exported `NewService` constructor returning the interface.
|
||||
- First constructor parameter is `factory.ProviderSettings`. Create scoped settings with `factory.NewScopedProviderSettings`.
|
||||
- Register in `factory.Registry` with `factory.NewNamedService`. The registry starts and stops everything.
|
||||
- Only return an error from `Start` if the failure is unrecoverable. Log and continue for transient errors in polling loops.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Google Guava - ServiceExplained](https://github.com/google/guava/wiki/ServiceExplained) - the service lifecycle pattern takes inspiration from this
|
||||
- [OpenTelemetry Collector](https://github.com/open-telemetry/opentelemetry-collector) - Worth studying for its approach to building composable components
|
||||
@@ -1,260 +0,0 @@
|
||||
# Testing
|
||||
|
||||
This document provides rules for writing tests that catch real bugs and do not become a maintenance burden. It covers both how to write good tests and how to recognize bad ones.
|
||||
|
||||
## Why we write tests
|
||||
|
||||
Tests exist to give confidence that the system behaves correctly. A good test suite lets you change code and know immediately (or in a reasonable time) whether you broke something. A bad test suite lets you change code (and then spend hours figuring out whether the failures are real) and still lets the bugs slip in.
|
||||
|
||||
Every test should be written to answer one question: **if this test fails, does that mean a user-visible behavior is broken?** If the answer is no, reconsider whether the test should exist.
|
||||
|
||||
Not all tests are equal. Different scopes serve different purposes, and the balance matters.
|
||||
|
||||
- **Unit tests**: Fast, focused, test a single function or type in isolation. These form the foundation. They should run in milliseconds, have no I/O, and be fully deterministic.
|
||||
- **Integration tests**: Verify that components work together against real dependencies (ClickHouse, PostgreSQL, etc.). Slower, but catch problems that unit tests cannot: real query behavior, configuration issues, serialization mismatches.
|
||||
- **End-to-end tests**: Validate full system behavior from the outside. Expensive to write and maintain, but necessary for critical user flows.
|
||||
|
||||
When a test can be written at a smaller scope, prefer the smaller scope. But do not force a unit test where an integration test is the natural fit.
|
||||
|
||||
## What to test
|
||||
|
||||
### Test behaviors, not implementations
|
||||
|
||||
A test should verify what the code does, not how it does it (unless the goal of the test is specifically how something happen). If you can refactor the internals of a function e.g, change a query, rename a variable, restructure the logic and no user-visible behavior changes, no test should break.
|
||||
|
||||
```go
|
||||
// Good: tests the behavior "given this input, expect this output."
|
||||
func TestDiscountApplied(t *testing.T) {
|
||||
order := NewOrder(item("widget", 100))
|
||||
order.ApplyDiscount(10)
|
||||
assert.Equal(t, 90, order.Total())
|
||||
}
|
||||
|
||||
// Bad: tests the implementation "did it call the right internal method?"
|
||||
func TestDiscountApplied(t *testing.T) {
|
||||
mockPricer := new(MockPricer)
|
||||
mockPricer.On("CalculateDiscount", 100, 10).Return(90)
|
||||
order := NewOrder(item("widget", 100), WithPricer(mockPricer))
|
||||
order.ApplyDiscount(10)
|
||||
mockPricer.AssertCalled(t, "CalculateDiscount", 100, 10)
|
||||
}
|
||||
```
|
||||
|
||||
The first test survives a refactoring of how discounts are calculated. The second test breaks the moment you rename the method, change its signature, or inline the logic.
|
||||
|
||||
**The refactoring test**: before committing a test, ask if someone refactors the internals tomorrow without changing any behavior, will this test break? If yes, consider updating the test.
|
||||
|
||||
### Output format as behavior
|
||||
|
||||
Some functions exist specifically to produce a formatted output: a query builder generates SQL, a serializer generates JSON, a code generator produces source code. In these cases, the output string *is* the behavior and asserting on it is valid and necessary. The function's contract is the exact output it produces.
|
||||
|
||||
This is different from testing a function that *uses* a query internally. If a function's job is to fetch data from a database, the query it sends is an implementation detail and the returned data is the behavior. If its job is to *build* a query for someone else to execute, the query string is the behavior.
|
||||
|
||||
The distinction: **is the formatted output the function's product, or the function's mechanism?** Test the product, not the mechanism.
|
||||
|
||||
### Test at the public API boundary
|
||||
|
||||
Write tests against the exported functions and methods that consumers actually call. Do not test unexported helpers directly. If an unexported function has complex logic worth testing, that is a signal it should be extracted into its own package with its own public API.
|
||||
|
||||
### Test edge cases and error paths
|
||||
|
||||
The most valuable tests cover the cases that are easy to get wrong:
|
||||
|
||||
- Empty inputs, nil inputs, zero values.
|
||||
- Boundary conditions (off-by-one, first element, last element).
|
||||
- Error conditions (what happens when the dependency fails?).
|
||||
- Concurrent access, if the code is designed for it.
|
||||
|
||||
A test for the happy path of a trivial function adds little value. A test for the error path of a complex function prevents real bugs.
|
||||
|
||||
### The Beyonce Rule
|
||||
|
||||
"If you liked it, then you should have put a test on it." Any behavior you want to preserve such as correctness, performance characteristics, security constraints, error handling should be covered by a test. If it breaks and there is no test, that is not a regression; it is an untested assumption.
|
||||
|
||||
## How to write a test
|
||||
|
||||
### Structure: arrange, act, assert
|
||||
|
||||
Every test should have three clearly separated sections:
|
||||
|
||||
```go
|
||||
func TestTransferInsufficientFunds(t *testing.T) {
|
||||
// Arrange: set up the preconditions.
|
||||
from := NewAccount(50)
|
||||
to := NewAccount(0)
|
||||
|
||||
// Act: perform the operation being tested.
|
||||
err := Transfer(from, to, 100)
|
||||
|
||||
// Assert: verify the outcome.
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 50, from.Balance())
|
||||
assert.Equal(t, 0, to.Balance())
|
||||
}
|
||||
```
|
||||
|
||||
Do not interleave setup and assertions. Do not put assertions in helper functions that also perform setup. Keep the three sections visually distinct.
|
||||
|
||||
### One behavior per test
|
||||
|
||||
Each test function should verify one behavior. If a test name needs "and" in it, split it into two tests.
|
||||
|
||||
```go
|
||||
// Good: one behavior per test.
|
||||
func TestParseValidInput(t *testing.T) { ... }
|
||||
func TestParseEmptyInput(t *testing.T) { ... }
|
||||
func TestParseMalformedInput(t *testing.T) { ... }
|
||||
|
||||
// Bad: multiple behaviors in one test.
|
||||
func TestParse(t *testing.T) {
|
||||
// test valid input
|
||||
// test empty input
|
||||
// test malformed input
|
||||
}
|
||||
```
|
||||
|
||||
Table-driven tests are fine when the behavior is the same and only the inputs/outputs vary.
|
||||
|
||||
### Name tests after behaviors
|
||||
|
||||
Test names should describe the scenario and the expected outcome, not the function being tested.
|
||||
|
||||
```go
|
||||
// Good: describes the behavior.
|
||||
func TestWithdrawal_InsufficientFunds_ReturnsError(t *testing.T)
|
||||
func TestWithdrawal_ZeroBalance_ReturnsError(t *testing.T)
|
||||
|
||||
// Bad: describes the function.
|
||||
func TestWithdraw(t *testing.T)
|
||||
func TestWithdrawError(t *testing.T)
|
||||
```
|
||||
|
||||
### Eliminate logic in tests
|
||||
|
||||
Tests should be straight-line code. No `if`, no `for`, no `switch`. If you feel the need to add control flow to a test, either split it into multiple tests or restructure the test data.
|
||||
|
||||
A test with logic in it needs its own tests. That is a sign something has gone wrong.
|
||||
|
||||
### Write clear failure messages
|
||||
|
||||
When a test fails, the failure message should tell you what went wrong without reading the test source.
|
||||
|
||||
```go
|
||||
// Good: failure message explains the context.
|
||||
assert.Equal(t, expected, actual, "discount should be applied to order total")
|
||||
|
||||
// Bad: failure message is just the default.
|
||||
assert.Equal(t, expected, actual)
|
||||
```
|
||||
|
||||
Use `require` for preconditions that must hold for the rest of the test to make sense. Use `assert` for the actual verifications. This avoids cascading failures from a single root cause.
|
||||
|
||||
## How to recognize a bad test
|
||||
|
||||
A bad test costs more to maintain than the bugs it prevents. Learning to identify bad tests is as important as learning to write good ones. Always evaluate a test critically before commiting it.
|
||||
|
||||
### Tests that duplicate the implementation
|
||||
|
||||
If a test contains the same logic as the code it tests, it verifies nothing. It will pass when the code is wrong in the same way the test is wrong, and it will break whenever the code changes even if the change is correct.
|
||||
|
||||
A common form: mocking a database, setting up canned rows, calling a function that queries and scans those rows, then asserting that the function returned exactly those rows. The test encodes the query, the row structure, and the scan logic. The same things the production code does. If the function has no branching logic beyond "query and scan," this test is a mirror of the implementation, not a check on it. An integration test against a real database verifies the actual behavior; the mock-based test verifies that the code matches the test author's expectations of the code.
|
||||
|
||||
### Tests for functions with no interesting logic
|
||||
|
||||
Not every function needs a test. A function that prepares a query, sends it, and scans the result has no branching, no edge cases, and no logic that could be wrong independently of the query being correct. Unit-testing it means mocking the database, which means the test does not verify the query works. It only verifies the function calls the mock in the expected way.
|
||||
|
||||
Ask: **what bug would this test catch that would not be caught by the integration test or by the tests of the calling code?** If the answer is nothing, skip the unit test. A missing test is better than a test that provides false confidence.
|
||||
|
||||
### Tests that rebuild the dependency boundary
|
||||
|
||||
When a test creates an in-package mock of an external interface (database driver, HTTP client, file system) and that mock contains non-trivial logic (reflection-based scanning, response simulation, state machines), the test is now testing its own mock as much as the production code. Bugs in the mock produce false passes or false failures, and the mock must be maintained alongside the real dependency.
|
||||
|
||||
If the mock is complex enough to have its own bugs, you have rebuilt the dependency boundary rather than testing against it. Use the real dependency (via integration test) or use a well-maintained fake provided by the dependency's authors.
|
||||
|
||||
### Tests that exist for coverage
|
||||
|
||||
A test that exercises a function without meaningfully verifying its output adds coverage without adding confidence. Calling a type-conversion function with every numeric type and asserting it does not panic covers lines but does not catch regressions. The function would need to be rewritten to fail, and any such rewrite would be caught by the callers' tests.
|
||||
|
||||
Before writing a test, identify the specific failure mode it guards against. If you cannot name one, the test is not worth writing.
|
||||
|
||||
### Tests that test the language
|
||||
|
||||
Do not test that language type system, standard library, or well-known third-party libraries work correctly. Testing that `reflect.Kind` returns the right value for each type, that pointer dereferencing works, or that a type switch dispatches correctly adds maintenance burden without catching any plausible bug in your code.
|
||||
|
||||
## Brittle tests
|
||||
|
||||
A brittle test is one that fails when production code changes without an actual bug being introduced. Brittle tests are expensive: they slow down development, train people to ignore failures, and provide no real safety net. Common sources of brittleness:
|
||||
|
||||
- **Asserting on implementation details**: Verifying which internal methods were called, in what order, or with what intermediate values. If the method is renamed or the order changes but the output is the same, the test breaks for no reason.
|
||||
- **Asserting on serialized representations when the format is not the contract**: Matching exact SQL strings, JSON output, or log messages produced by a function whose job is not to produce that format.
|
||||
- **Over-constrained mocks**: Setting up a mock that expects specific arguments in a specific sequence. Any refactoring of the call pattern breaks the mock setup even if behavior is preserved.
|
||||
- **Shared mutable state**: Tests that depend on data left behind by other tests. A change in execution order or a new test case causes unrelated failures.
|
||||
- **Time-dependence**: Tests that use `time.Now()`, `time.Sleep()`, or real timers. These produce flaky results and break under load.
|
||||
|
||||
When you encounter a brittle test, fix or delete it. Do not work around it.
|
||||
|
||||
## DAMP
|
||||
|
||||
Test code should prioritize clarity (DAMP: Descriptive And Meaningful Phrases).
|
||||
|
||||
```go
|
||||
// DAMP: each test is self-contained and readable.
|
||||
func TestCreateUser(t *testing.T) {
|
||||
user := User{Name: "Alice", Email: "alice@example.com"}
|
||||
err := store.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreateDuplicateUser(t *testing.T) {
|
||||
user := User{Name: "Alice", Email: "alice@example.com"}
|
||||
_ = store.Create(ctx, user)
|
||||
err := store.Create(ctx, user)
|
||||
assert.ErrorIs(t, err, ErrAlreadyExists)
|
||||
}
|
||||
```
|
||||
|
||||
Shared setup helpers are fine for constructing objects with sensible defaults. But each test should explicitly set the values it depends on rather than relying on hidden defaults in a shared fixture.
|
||||
|
||||
## Flaky tests
|
||||
|
||||
A flaky test is one that sometimes passes and sometimes fails without any code change. Flaky tests erode trust in the entire suite. Once people learn to re-run and ignore failures, real bugs slip through.
|
||||
|
||||
Common causes and fixes:
|
||||
|
||||
- **Timing and sleeps**: Replace `time.Sleep` with channels, condition variables, or polling with a timeout.
|
||||
- **Uncontrolled concurrency**: Use deterministic synchronization rather than relying on goroutine scheduling.
|
||||
- **Shared state between tests**: Each test should set up and tear down its own state.
|
||||
|
||||
If a test is flaky and you cannot fix the root cause quickly, skip or delete it. A skipped test with an explanation is better than a flaky test that trains everyone to ignore red builds.
|
||||
|
||||
## Code coverage
|
||||
|
||||
Code coverage measures which lines were executed, not whether the code is correct. A function that is called but whose output is never checked has 100% coverage and 0% verification.
|
||||
|
||||
Do not use coverage as a target to hit. Use it as a tool to find gaps such as untested error paths, unreachable branches, dead code. A codebase with 60% meaningful coverage is better than one with 95% coverage achieved by testing trivial getters.
|
||||
|
||||
## Tests are code
|
||||
|
||||
Tests must be maintained and they are not second-class citizen. You should apply the same standards for readability, naming, and structure that you apply to production code. We do not tolerate complexity in tests just because they are tests.
|
||||
|
||||
However, tests should be simpler than production code. If a test requires its own helper library, complex setup, or nested control flow, step back and ask whether you are testing the right thing at the right level. This is not a blanket rule but a prompt to pause, assess the situation, and check whether the complexity is justified.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- If refactoring internals breaks your test but no behavior changed, the test is likely bad. Delete it or consider updating it.
|
||||
- Test what the code does, not how it does it. Verify outputs and state, not method calls.
|
||||
- Output format is behavior when the function's job is to produce that format. It is not behavior when the function uses it internally.
|
||||
- Ask what specific bug this test catches. If you cannot name one, do not write it.
|
||||
- Always evaluate whether the test adds confidence, not just lines.
|
||||
- One behavior per test. Name it after the scenario, not the function.
|
||||
- No logic in tests. Straight-line code only.
|
||||
- Flaky tests are not acceptable. Fix the root cause or nuke the test code.
|
||||
- Coverage measures execution, not correctness.
|
||||
|
||||
## Mandatory reading
|
||||
|
||||
- What to look for in a code review: Tests - https://google.github.io/eng-practices/review/reviewer/looking-for.html#tests
|
||||
- Testing Overview - https://abseil.io/resources/swe-book/html/ch11.html
|
||||
- Unit Testing - https://abseil.io/resources/swe-book/html/ch12.html
|
||||
- Test Doubles - https://abseil.io/resources/swe-book/html/ch13.html
|
||||
- Larger Testing - https://abseil.io/resources/swe-book/html/ch14.html
|
||||
@@ -1,272 +0,0 @@
|
||||
# Types
|
||||
|
||||
This guide covers how types are organised, named, constructed, and composed so you can add new ones consistently.
|
||||
|
||||
## Where do types live?
|
||||
|
||||
Types live in `pkg/types/` and its sub-packages:
|
||||
|
||||
```
|
||||
pkg/types/
|
||||
├── auditable.go # TimeAuditable, UserAuditable
|
||||
├── identity.go # Identifiable (UUID primary key)
|
||||
├── user.go # User, PostableRegisterOrgAndAdmin, UserStore
|
||||
├── alertmanagertypes/ # Alert manager domain types
|
||||
│ ├── channel.go
|
||||
│ ├── receiver.go
|
||||
│ └── config.go
|
||||
├── authtypes/ # Auth domain types
|
||||
└── ruletypes/ # Rule domain types
|
||||
└── maintenance.go
|
||||
```
|
||||
|
||||
Follow these rules:
|
||||
|
||||
1. **Embeddable building blocks** go in `pkg/types/` directly `Identifiable`, `TimeAuditable`, `UserAuditable`.
|
||||
2. **Domain-specific types** go in a sub-package named `pkg/types/<domain>types/` (e.g., `alertmanagertypes`, `ruletypes`, `authtypes`).
|
||||
3. **No domain logic** in type packages. Only data structures, constants, and simple methods. Domain services import from type packages, not the other way around.
|
||||
4. **Domain services import types, not vice versa.** If a type needs a service, the design is likely wrong and you should restructure so the service operates on the type.
|
||||
|
||||
## Type variants
|
||||
|
||||
A domain entity often has multiple representations depending on where it appears in the system. We use naming prefixes to distinguish them:
|
||||
|
||||
| Prefix | Purpose | Example |
|
||||
|---|---|---|
|
||||
| `Postable<Type>` | API request input | `PostableRegisterOrgAndAdmin` |
|
||||
| `Gettable<Type>` | API response output | `GettablePlannedMaintenance` |
|
||||
| `Storable<Type>` | Database model (embeds `bun.BaseModel`) | `StorablePlannedMaintenance` |
|
||||
| Plain `<Type>` | Domain logic type | `User` |
|
||||
|
||||
Not every entity needs all four variants. Start with the plain type and add variants only when the API or database representation genuinely differs.
|
||||
|
||||
Here is a concrete example from `pkg/types/ruletypes/maintenance.go`:
|
||||
|
||||
```go
|
||||
// Database model embeds bun.BaseModel and composition types
|
||||
type StorablePlannedMaintenance struct {
|
||||
bun.BaseModel `bun:"table:planned_maintenance"`
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
Description string `bun:"description,type:text"`
|
||||
Schedule *Schedule `bun:"schedule,type:text,notnull"`
|
||||
OrgID string `bun:"org_id,type:text"`
|
||||
}
|
||||
|
||||
// API response: flat struct with JSON tags, computed fields like Status
|
||||
type GettablePlannedMaintenance struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Schedule *Schedule `json:"schedule"`
|
||||
RuleIDs []string `json:"alertIds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
Status string `json:"status"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
```
|
||||
|
||||
When the API shape exactly matches the domain type, use a type alias instead of duplicating fields:
|
||||
|
||||
```go
|
||||
// From pkg/types/user.go
|
||||
type GettableUser = User
|
||||
```
|
||||
|
||||
## Composition via embedding
|
||||
|
||||
`pkg/types/` provides small, reusable structs that you embed into your domain types:
|
||||
|
||||
```go
|
||||
// pkg/types/identity.go
|
||||
type Identifiable struct {
|
||||
ID valuer.UUID `json:"id" bun:"id,pk,type:text"`
|
||||
}
|
||||
|
||||
// pkg/types/auditable.go
|
||||
type TimeAuditable struct {
|
||||
CreatedAt time.Time `bun:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `bun:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type UserAuditable struct {
|
||||
CreatedBy string `bun:"created_by,type:text" json:"createdBy"`
|
||||
UpdatedBy string `bun:"updated_by,type:text" json:"updatedBy"`
|
||||
}
|
||||
```
|
||||
|
||||
Compose them in a database model:
|
||||
|
||||
```go
|
||||
type StorablePlannedMaintenance struct {
|
||||
bun.BaseModel `bun:"table:planned_maintenance"`
|
||||
types.Identifiable // adds ID (UUID primary key)
|
||||
types.TimeAuditable // adds CreatedAt, UpdatedAt
|
||||
types.UserAuditable // adds CreatedBy, UpdatedBy
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
Description string `bun:"description,type:text"`
|
||||
}
|
||||
```
|
||||
|
||||
See [SQL](sql.md) for full database patterns including migrations and queries.
|
||||
|
||||
## Constructors
|
||||
|
||||
Constructors validate inputs and return a ready-to-use value:
|
||||
|
||||
```go
|
||||
// New<Type> validates and returns a pointer + error
|
||||
func NewUser(displayName string, email valuer.Email, role Role, orgID valuer.UUID) (*User, error) {
|
||||
if email.IsZero() {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required")
|
||||
}
|
||||
if role == "" {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "role is required")
|
||||
}
|
||||
if orgID.IsZero() {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgID is required")
|
||||
}
|
||||
|
||||
return &User{
|
||||
Identifiable: Identifiable{ID: valuer.GenerateUUID()},
|
||||
DisplayName: displayName,
|
||||
Email: email,
|
||||
Role: role,
|
||||
OrgID: orgID,
|
||||
TimeAuditable: TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Follow these conventions:
|
||||
|
||||
- **`New<Type>(args) (*Type, error)`**: validates inputs, returns an error on failure. Use this in production code.
|
||||
- **Validation at construction**: check required fields, format constraints, and invariants in the constructor. Callers should not need to validate after construction.
|
||||
- **Generate IDs internally**: constructors call `valuer.GenerateUUID()` callers do not pass IDs in.
|
||||
- **Set timestamps internally**: constructors set `CreatedAt` and `UpdatedAt` to `time.Now()`.
|
||||
|
||||
## Typed domain values (`pkg/valuer/`)
|
||||
|
||||
The `pkg/valuer` package provides typed wrappers for common domain values. These types carry validation, normalization, and consistent serialization (JSON, SQL, text) that raw Go primitives do not.
|
||||
|
||||
| Type | Wraps | Invariant |
|
||||
|---|---|---|
|
||||
| `valuer.UUID` | `google/uuid.UUID` | Valid UUIDv7, generated via `GenerateUUID()` |
|
||||
| `valuer.Email` | `string` | Valid email format, lowercased and trimmed |
|
||||
| `valuer.String` | `string` | Lowercased and trimmed |
|
||||
| `valuer.TextDuration` | `time.Duration` | Valid duration, text-serializable |
|
||||
|
||||
### When to use a valuer type
|
||||
|
||||
Use a valuer type instead of a raw primitive when the value represents a domain concept with any of:
|
||||
|
||||
- **Enums**: All enums in the codebase must be backed by `valuer.String`. Do not use raw `string` constants or `iota`-based `int` enums. A struct embedding `valuer.String` with predefined variables gives you normalization, serialization, and an `Enum()` method for OpenAPI schema generation in one place.
|
||||
- **Validation**: emails must match a format, UUIDs must be parseable, durations must be valid.
|
||||
- **Normalization**: `valuer.String` lowercases and trims input, so comparisons are consistent throughout the system.
|
||||
- **Serialization boundary**: the value is stored in a database, sent over the wire, or bound from an HTTP parameter. Valuer types implement `Scan`, `Value`, `MarshalJSON`, `UnmarshalJSON`, and `UnmarshalParam` consistently.
|
||||
|
||||
```go
|
||||
// Wrong: raw string constant with no validation or normalization.
|
||||
const SignalTraces = "traces"
|
||||
|
||||
// Right: valuer-backed type that normalizes and serializes consistently.
|
||||
type Signal struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var SignalTraces = Signal{valuer.NewString("traces")}
|
||||
```
|
||||
|
||||
Only primitive domain types that serve as shared infrastructure belong in `pkg/valuer`. If you need a new base type (like `Email` or `TextDuration`) that multiple packages will embed for validation and serialization, add it there. Domain-specific types that build on top of a valuer (like `Signal` embedding `valuer.String`) belong in their own domain package, not in `pkg/valuer`.
|
||||
|
||||
### The `Valuer` interface
|
||||
|
||||
Every valuer type implements the `Valuer` interface, which gives you serialization for free:
|
||||
|
||||
```go
|
||||
type Valuer interface {
|
||||
IsZero() bool // check for zero value
|
||||
StringValue() string // raw string representation
|
||||
fmt.Stringer // String() for printing
|
||||
json.Marshaler / json.Unmarshaler // JSON
|
||||
sql.Scanner / driver.Valuer // database
|
||||
encoding.TextMarshaler / TextUnmarshaler // text
|
||||
ginbinding.BindUnmarshaler // HTTP query/path params
|
||||
}
|
||||
```
|
||||
|
||||
Use them in struct fields:
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
Identifiable
|
||||
Email valuer.Email `bun:"email" json:"email"`
|
||||
OrgID valuer.UUID `bun:"org_id" json:"orgId"`
|
||||
}
|
||||
```
|
||||
|
||||
## Wrappers must add semantics, not just rename
|
||||
|
||||
A wrapper type is justified when it adds meaning, validation, or invariants that the underlying type does not carry. It is not justified when it merely renames fields or reorganizes the same data into a different shape.
|
||||
|
||||
```go
|
||||
// Justified: adds validation that the underlying string does not carry.
|
||||
type OrgID struct{ value string }
|
||||
func NewOrgID(s string) (OrgID, error) { /* validates format */ }
|
||||
|
||||
// Not justified: renames fields with no new invariant or behavior.
|
||||
type UserInfo struct {
|
||||
Name string // same as source.Name
|
||||
Email string // same as source.Email
|
||||
}
|
||||
```
|
||||
|
||||
Ask: what does the wrapper guarantee that the underlying type does not? If the answer is nothing, use the underlying type directly.
|
||||
|
||||
## When a new type IS warranted
|
||||
|
||||
A new type earns its place when it meets **at least one** of these criteria:
|
||||
|
||||
- **Serialization boundary**: It must be persisted, sent over the wire, or written to config. The source type is unsuitable (unexported fields, function pointers, cycles).
|
||||
- **Invariant enforcement**: The constructor or methods enforce constraints that raw data does not carry (e.g., non-empty, validated format, bounded range).
|
||||
- **Multiple distinct consumers**: Three or more call sites use the type in meaningfully different ways. The type is the shared vocabulary between them.
|
||||
- **Dependency firewall**: The type lives in a lightweight package so that consumers avoid importing a heavy dependency.
|
||||
|
||||
See [Abstractions](abstractions.md) for the full set of rules on when abstractions are and aren't justified.
|
||||
|
||||
## Store interfaces
|
||||
|
||||
Each domain type package defines a store interface for persistence. The store interface lives alongside the types it operates on:
|
||||
|
||||
```go
|
||||
// From pkg/types/ruletypes/maintenance.go
|
||||
type MaintenanceStore interface {
|
||||
CreatePlannedMaintenance(context.Context, GettablePlannedMaintenance) (valuer.UUID, error)
|
||||
DeletePlannedMaintenance(context.Context, valuer.UUID) error
|
||||
GetPlannedMaintenanceByID(context.Context, valuer.UUID) (*GettablePlannedMaintenance, error)
|
||||
EditPlannedMaintenance(context.Context, GettablePlannedMaintenance, valuer.UUID) error
|
||||
GetAllPlannedMaintenance(context.Context, string) ([]*GettablePlannedMaintenance, error)
|
||||
}
|
||||
```
|
||||
|
||||
Conventions:
|
||||
|
||||
- Name the interface `<Domain>Store` (e.g., `UserStore`, `MaintenanceStore`).
|
||||
- Accept `context.Context` as the first parameter.
|
||||
- Use typed values (`valuer.UUID`, `valuer.Email`) instead of raw strings for identifiers.
|
||||
- Implementations go in separate packages (e.g., `sqlstore/`), see [SQL](sql.md) for details.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- Shared types live in `pkg/types/`, domain types in `pkg/types/<domain>types/`.
|
||||
- No domain logic in type packages only data structures, constants, and simple methods.
|
||||
- Use `Storable`, `Gettable`, `Postable` prefixes when API or database representation differs from the domain type.
|
||||
- Embed `Identifiable`, `TimeAuditable`, and `UserAuditable` for standard fields instead of repeating them.
|
||||
- Constructors (`New<Type>`) validate, generate IDs, and set timestamps.
|
||||
- Use `pkg/valuer/` types instead of raw strings for domain identifiers like UUIDs and emails.
|
||||
- Store interfaces live alongside the types they persist and use `context.Context` as the first parameter.
|
||||
@@ -98,20 +98,16 @@ func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.
|
||||
return provider.pkgAuthzService.ListByOrgIDAndNames(ctx, orgID, names)
|
||||
}
|
||||
|
||||
func (provider *provider) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*roletypes.Role, error) {
|
||||
return provider.pkgAuthzService.ListByOrgIDAndIDs(ctx, orgID, ids)
|
||||
func (provider *provider) Grant(ctx context.Context, orgID valuer.UUID, name string, subject string) error {
|
||||
return provider.pkgAuthzService.Grant(ctx, orgID, name, subject)
|
||||
}
|
||||
|
||||
func (provider *provider) Grant(ctx context.Context, orgID valuer.UUID, names []string, subject string) error {
|
||||
return provider.pkgAuthzService.Grant(ctx, orgID, names, subject)
|
||||
func (provider *provider) ModifyGrant(ctx context.Context, orgID valuer.UUID, existingRoleName string, updatedRoleName string, subject string) error {
|
||||
return provider.pkgAuthzService.ModifyGrant(ctx, orgID, existingRoleName, updatedRoleName, subject)
|
||||
}
|
||||
|
||||
func (provider *provider) ModifyGrant(ctx context.Context, orgID valuer.UUID, existingRoleNames []string, updatedRoleNames []string, subject string) error {
|
||||
return provider.pkgAuthzService.ModifyGrant(ctx, orgID, existingRoleNames, updatedRoleNames, subject)
|
||||
}
|
||||
|
||||
func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, names []string, subject string) error {
|
||||
return provider.pkgAuthzService.Revoke(ctx, orgID, names, subject)
|
||||
func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, name string, subject string) error {
|
||||
return provider.pkgAuthzService.Revoke(ctx, orgID, name, subject)
|
||||
}
|
||||
|
||||
func (provider *provider) CreateManagedRoles(ctx context.Context, orgID valuer.UUID, managedRoles []*roletypes.Role) error {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -10,7 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
@@ -27,12 +27,12 @@ type APIHandlerOptions struct {
|
||||
RulesManager *rules.Manager
|
||||
UsageManager *usage.Manager
|
||||
IntegrationsController *integrations.Controller
|
||||
CloudIntegrationsController *cloudintegrations.Controller
|
||||
LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController
|
||||
GatewayUrl string
|
||||
// Querier Influx Interval
|
||||
FluxInterval time.Duration
|
||||
GlobalConfig global.Config
|
||||
Logger *slog.Logger // this is present in Signoz.Instrumentation but adding for quick access
|
||||
}
|
||||
|
||||
type APIHandler struct {
|
||||
@@ -46,13 +46,13 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
|
||||
Reader: opts.DataConnector,
|
||||
RuleManager: opts.RulesManager,
|
||||
IntegrationsController: opts.IntegrationsController,
|
||||
CloudIntegrationsController: opts.CloudIntegrationsController,
|
||||
LogsParsingPipelineController: opts.LogsParsingPipelineController,
|
||||
FluxInterval: opts.FluxInterval,
|
||||
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
|
||||
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
|
||||
Signoz: signoz,
|
||||
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
|
||||
Logger: opts.Logger,
|
||||
}, config)
|
||||
|
||||
if err != nil {
|
||||
@@ -101,14 +101,12 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
}
|
||||
|
||||
func (ah *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
|
||||
ah.APIHandler.RegisterCloudIntegrationsRoutes(router, am)
|
||||
|
||||
router.HandleFunc(
|
||||
"/api/v1/cloud-integrations/{cloudProvider}/accounts/generate-connection-params",
|
||||
am.EditAccess(ah.CloudIntegrationsGenerateConnectionParams),
|
||||
).Methods(http.MethodGet)
|
||||
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getVersion(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,20 +14,14 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type CloudIntegrationConnectionParamsResponse struct {
|
||||
IngestionUrl string `json:"ingestion_url,omitempty"`
|
||||
IngestionKey string `json:"ingestion_key,omitempty"`
|
||||
SigNozAPIUrl string `json:"signoz_api_url,omitempty"`
|
||||
SigNozAPIKey string `json:"signoz_api_key,omitempty"`
|
||||
}
|
||||
// TODO: move this file with other cloud integration related code
|
||||
|
||||
func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
@@ -41,23 +36,21 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
|
||||
return
|
||||
}
|
||||
|
||||
cloudProvider := mux.Vars(r)["cloudProvider"]
|
||||
if cloudProvider != "aws" {
|
||||
RespondError(w, basemodel.BadRequest(fmt.Errorf(
|
||||
"cloud provider not supported: %s", cloudProvider,
|
||||
)), nil)
|
||||
cloudProviderString := mux.Vars(r)["cloudProvider"]
|
||||
|
||||
cloudProvider, err := integrationtypes.NewCloudProvider(cloudProviderString)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, apiErr := ah.getOrCreateCloudIntegrationPAT(r.Context(), claims.OrgID, cloudProvider)
|
||||
if apiErr != nil {
|
||||
RespondError(w, basemodel.WrapApiError(
|
||||
apiErr, "couldn't provision PAT for cloud integration:",
|
||||
), nil)
|
||||
apiKey, err := ah.getOrCreateCloudIntegrationPAT(r.Context(), claims.OrgID, cloudProvider)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result := CloudIntegrationConnectionParamsResponse{
|
||||
result := integrationtypes.GettableCloudIntegrationConnectionParams{
|
||||
SigNozAPIKey: apiKey,
|
||||
}
|
||||
|
||||
@@ -71,16 +64,17 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
|
||||
// Return the API Key (PAT) even if the rest of the params can not be deduced.
|
||||
// Params not returned from here will be requested from the user via form inputs.
|
||||
// This enables gracefully degraded but working experience even for non-cloud deployments.
|
||||
zap.L().Info("ingestion params and signoz api url can not be deduced since no license was found")
|
||||
ah.Respond(w, result)
|
||||
ah.opts.Logger.InfoContext(
|
||||
r.Context(),
|
||||
"ingestion params and signoz api url can not be deduced since no license was found",
|
||||
)
|
||||
render.Success(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
|
||||
signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
|
||||
if apiErr != nil {
|
||||
RespondError(w, basemodel.WrapApiError(
|
||||
apiErr, "couldn't deduce ingestion url and signoz api url",
|
||||
), nil)
|
||||
signozApiUrl, err := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -89,48 +83,41 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
|
||||
|
||||
gatewayUrl := ah.opts.GatewayUrl
|
||||
if len(gatewayUrl) > 0 {
|
||||
|
||||
ingestionKey, apiErr := getOrCreateCloudProviderIngestionKey(
|
||||
ingestionKeyString, err := ah.getOrCreateCloudProviderIngestionKey(
|
||||
r.Context(), gatewayUrl, license.Key, cloudProvider,
|
||||
)
|
||||
if apiErr != nil {
|
||||
RespondError(w, basemodel.WrapApiError(
|
||||
apiErr, "couldn't get or create ingestion key",
|
||||
), nil)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result.IngestionKey = ingestionKey
|
||||
|
||||
result.IngestionKey = ingestionKeyString
|
||||
} else {
|
||||
zap.L().Info("ingestion key can't be deduced since no gateway url has been configured")
|
||||
ah.opts.Logger.InfoContext(
|
||||
r.Context(),
|
||||
"ingestion key can't be deduced since no gateway url has been configured",
|
||||
)
|
||||
}
|
||||
|
||||
ah.Respond(w, result)
|
||||
render.Success(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId string, cloudProvider string) (
|
||||
string, *basemodel.ApiError,
|
||||
) {
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId string, cloudProvider valuer.String) (string, error) {
|
||||
integrationPATName := fmt.Sprintf("%s integration", cloudProvider)
|
||||
|
||||
integrationUser, apiErr := ah.getOrCreateCloudIntegrationUser(ctx, orgId, cloudProvider)
|
||||
if apiErr != nil {
|
||||
return "", apiErr
|
||||
integrationUser, err := ah.getOrCreateCloudIntegrationUser(ctx, orgId, cloudProvider)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
orgIdUUID, err := valuer.NewUUID(orgId)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't parse orgId: %w", err,
|
||||
))
|
||||
return "", err
|
||||
}
|
||||
|
||||
allPats, err := ah.Signoz.Modules.User.ListAPIKeys(ctx, orgIdUUID)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't list PATs: %w", err,
|
||||
))
|
||||
return "", err
|
||||
}
|
||||
for _, p := range allPats {
|
||||
if p.UserID == integrationUser.ID && p.Name == integrationPATName {
|
||||
@@ -138,9 +125,10 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
|
||||
}
|
||||
}
|
||||
|
||||
zap.L().Info(
|
||||
ah.opts.Logger.InfoContext(
|
||||
ctx,
|
||||
"no PAT found for cloud integration, creating a new one",
|
||||
zap.String("cloudProvider", cloudProvider),
|
||||
slog.String("cloudProvider", cloudProvider.String()),
|
||||
)
|
||||
|
||||
newPAT, err := types.NewStorableAPIKey(
|
||||
@@ -150,68 +138,48 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't create cloud integration PAT: %w", err,
|
||||
))
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = ah.Signoz.Modules.User.CreateAPIKey(ctx, newPAT)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't create cloud integration PAT: %w", err,
|
||||
))
|
||||
return "", err
|
||||
}
|
||||
return newPAT.Token, nil
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationUser(
|
||||
ctx context.Context, orgId string, cloudProvider string,
|
||||
) (*types.User, *basemodel.ApiError) {
|
||||
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
|
||||
// TODO: move this function out of handler and use proper module structure
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationUser(ctx context.Context, orgId string, cloudProvider valuer.String) (*types.User, error) {
|
||||
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider.String())
|
||||
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
|
||||
|
||||
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId))
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
|
||||
|
||||
cloudIntegrationUser, err = ah.Signoz.Modules.User.GetOrCreateUser(ctx, cloudIntegrationUser, user.WithFactorPassword(password))
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't look for integration user: %w", err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cloudIntegrationUser, nil
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
|
||||
string, *basemodel.ApiError,
|
||||
) {
|
||||
// TODO: remove this struct from here
|
||||
type deploymentResponse struct {
|
||||
Name string `json:"name"`
|
||||
ClusterInfo struct {
|
||||
Region struct {
|
||||
DNS string `json:"dns"`
|
||||
} `json:"region"`
|
||||
} `json:"cluster"`
|
||||
}
|
||||
|
||||
// TODO: move this function out of handler and use proper module structure
|
||||
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (string, error) {
|
||||
respBytes, err := ah.Signoz.Zeus.GetDeployment(ctx, licenseKey)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't query for deployment info: error: %w", err,
|
||||
))
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't query for deployment info: error")
|
||||
}
|
||||
|
||||
resp := new(deploymentResponse)
|
||||
resp := new(integrationtypes.GettableDeployment)
|
||||
|
||||
err = json.Unmarshal(respBytes, resp)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't unmarshal deployment info response: error: %w", err,
|
||||
))
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't unmarshal deployment info response")
|
||||
}
|
||||
|
||||
regionDns := resp.ClusterInfo.Region.DNS
|
||||
@@ -219,9 +187,10 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
|
||||
|
||||
if len(regionDns) < 1 || len(deploymentName) < 1 {
|
||||
// Fail early if actual response structure and expectation here ever diverge
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
return "", errors.NewInternalf(
|
||||
errors.CodeInternal,
|
||||
"deployment info response not in expected shape. couldn't determine region dns and deployment name",
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
signozApiUrl := fmt.Sprintf("https://%s.%s", deploymentName, regionDns)
|
||||
@@ -229,102 +198,85 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
|
||||
return signozApiUrl, nil
|
||||
}
|
||||
|
||||
type ingestionKey struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
// other attributes from gateway response not included here since they are not being used.
|
||||
}
|
||||
|
||||
type ingestionKeysSearchResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data []ingestionKey `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type createIngestionKeyResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data ingestionKey `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func getOrCreateCloudProviderIngestionKey(
|
||||
ctx context.Context, gatewayUrl string, licenseKey string, cloudProvider string,
|
||||
) (string, *basemodel.ApiError) {
|
||||
func (ah *APIHandler) getOrCreateCloudProviderIngestionKey(
|
||||
ctx context.Context, gatewayUrl string, licenseKey string, cloudProvider valuer.String,
|
||||
) (string, error) {
|
||||
cloudProviderKeyName := fmt.Sprintf("%s-integration", cloudProvider)
|
||||
|
||||
// see if the key already exists
|
||||
searchResult, apiErr := requestGateway[ingestionKeysSearchResponse](
|
||||
searchResult, err := requestGateway[integrationtypes.GettableIngestionKeysSearch](
|
||||
ctx,
|
||||
gatewayUrl,
|
||||
licenseKey,
|
||||
fmt.Sprintf("/v1/workspaces/me/keys/search?name=%s", cloudProviderKeyName),
|
||||
nil,
|
||||
ah.opts.Logger,
|
||||
)
|
||||
|
||||
if apiErr != nil {
|
||||
return "", basemodel.WrapApiError(
|
||||
apiErr, "couldn't search for cloudprovider ingestion key",
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if searchResult.Status != "success" {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't search for cloudprovider ingestion key: status: %s, error: %s",
|
||||
return "", errors.NewInternalf(
|
||||
errors.CodeInternal,
|
||||
"couldn't search for cloud provider ingestion key: status: %s, error: %s",
|
||||
searchResult.Status, searchResult.Error,
|
||||
))
|
||||
}
|
||||
|
||||
for _, k := range searchResult.Data {
|
||||
if k.Name == cloudProviderKeyName {
|
||||
if len(k.Value) < 1 {
|
||||
// Fail early if actual response structure and expectation here ever diverge
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"ingestion keys search response not as expected",
|
||||
))
|
||||
}
|
||||
|
||||
return k.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
zap.L().Info(
|
||||
"no existing ingestion key found for cloud integration, creating a new one",
|
||||
zap.String("cloudProvider", cloudProvider),
|
||||
)
|
||||
createKeyResult, apiErr := requestGateway[createIngestionKeyResponse](
|
||||
ctx, gatewayUrl, licenseKey, "/v1/workspaces/me/keys",
|
||||
map[string]any{
|
||||
"name": cloudProviderKeyName,
|
||||
"tags": []string{"integration", cloudProvider},
|
||||
},
|
||||
)
|
||||
if apiErr != nil {
|
||||
return "", basemodel.WrapApiError(
|
||||
apiErr, "couldn't create cloudprovider ingestion key",
|
||||
)
|
||||
}
|
||||
|
||||
for _, k := range searchResult.Data {
|
||||
if k.Name != cloudProviderKeyName {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(k.Value) < 1 {
|
||||
// Fail early if actual response structure and expectation here ever diverge
|
||||
return "", errors.NewInternalf(errors.CodeInternal, "ingestion keys search response not as expected")
|
||||
}
|
||||
|
||||
return k.Value, nil
|
||||
}
|
||||
|
||||
ah.opts.Logger.InfoContext(
|
||||
ctx,
|
||||
"no existing ingestion key found for cloud integration, creating a new one",
|
||||
slog.String("cloudProvider", cloudProvider.String()),
|
||||
)
|
||||
|
||||
createKeyResult, err := requestGateway[integrationtypes.GettableCreateIngestionKey](
|
||||
ctx, gatewayUrl, licenseKey, "/v1/workspaces/me/keys",
|
||||
map[string]any{
|
||||
"name": cloudProviderKeyName,
|
||||
"tags": []string{"integration", cloudProvider.String()},
|
||||
},
|
||||
ah.opts.Logger,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if createKeyResult.Status != "success" {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't create cloudprovider ingestion key: status: %s, error: %s",
|
||||
return "", errors.NewInternalf(
|
||||
errors.CodeInternal,
|
||||
"couldn't create cloud provider ingestion key: status: %s, error: %s",
|
||||
createKeyResult.Status, createKeyResult.Error,
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
ingestionKey := createKeyResult.Data.Value
|
||||
if len(ingestionKey) < 1 {
|
||||
ingestionKeyString := createKeyResult.Data.Value
|
||||
if len(ingestionKeyString) < 1 {
|
||||
// Fail early if actual response structure and expectation here ever diverge
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
return "", errors.NewInternalf(errors.CodeInternal,
|
||||
"ingestion key creation response not as expected",
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
return ingestionKey, nil
|
||||
return ingestionKeyString, nil
|
||||
}
|
||||
|
||||
func requestGateway[ResponseType any](
|
||||
ctx context.Context, gatewayUrl string, licenseKey string, path string, payload any,
|
||||
) (*ResponseType, *basemodel.ApiError) {
|
||||
ctx context.Context, gatewayUrl, licenseKey, path string, payload any, logger *slog.Logger,
|
||||
) (*ResponseType, error) {
|
||||
|
||||
baseUrl := strings.TrimSuffix(gatewayUrl, "/")
|
||||
reqUrl := fmt.Sprintf("%s%s", baseUrl, path)
|
||||
@@ -335,13 +287,12 @@ func requestGateway[ResponseType any](
|
||||
"X-Consumer-Groups": "ns:default",
|
||||
}
|
||||
|
||||
return requestAndParseResponse[ResponseType](ctx, reqUrl, headers, payload)
|
||||
return requestAndParseResponse[ResponseType](ctx, reqUrl, headers, payload, logger)
|
||||
}
|
||||
|
||||
func requestAndParseResponse[ResponseType any](
|
||||
ctx context.Context, url string, headers map[string]string, payload any,
|
||||
) (*ResponseType, *basemodel.ApiError) {
|
||||
|
||||
ctx context.Context, url string, headers map[string]string, payload any, logger *slog.Logger,
|
||||
) (*ResponseType, error) {
|
||||
reqMethod := http.MethodGet
|
||||
var reqBody io.Reader
|
||||
if payload != nil {
|
||||
@@ -349,18 +300,14 @@ func requestAndParseResponse[ResponseType any](
|
||||
|
||||
bodyJson, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't serialize request payload to JSON: %w", err,
|
||||
))
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't marshal payload")
|
||||
}
|
||||
reqBody = bytes.NewBuffer([]byte(bodyJson))
|
||||
reqBody = bytes.NewBuffer(bodyJson)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, reqMethod, url, reqBody)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't prepare request: %w", err,
|
||||
))
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't create req")
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
@@ -373,23 +320,26 @@ func requestAndParseResponse[ResponseType any](
|
||||
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't make request: %w", err))
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't make req")
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
defer func() {
|
||||
err = response.Body.Close()
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "couldn't close response body", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
respBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't read response: %w", err))
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read response body")
|
||||
}
|
||||
|
||||
var resp ResponseType
|
||||
|
||||
err = json.Unmarshal(respBody, &resp)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't unmarshal gateway response into %T", resp,
|
||||
))
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't unmarshal response body")
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
|
||||
@@ -37,7 +37,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
|
||||
@@ -121,13 +120,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
)
|
||||
}
|
||||
|
||||
cloudIntegrationsController, err := cloudintegrations.NewController(signoz.SQLStore)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"couldn't create cloud provider integrations controller: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// ingestion pipelines manager
|
||||
logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(
|
||||
signoz.SQLStore,
|
||||
@@ -161,11 +153,11 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
RulesManager: rm,
|
||||
UsageManager: usageManager,
|
||||
IntegrationsController: integrationsController,
|
||||
CloudIntegrationsController: cloudIntegrationsController,
|
||||
LogsParsingPipelineController: logParsingPipelineController,
|
||||
FluxInterval: config.Querier.FluxInterval,
|
||||
GatewayUrl: config.Gateway.URL.String(),
|
||||
GlobalConfig: config.Global,
|
||||
Logger: signoz.Instrumentation.Logger(),
|
||||
}
|
||||
|
||||
apiHandler, err := api.NewAPIHandler(apiOpts, signoz, config)
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
* ESLint Configuration for SigNoz Frontend
|
||||
*/
|
||||
module.exports = {
|
||||
ignorePatterns: [
|
||||
'src/parser/*.ts',
|
||||
'scripts/update-registry.js',
|
||||
'scripts/generate-permissions-type.js',
|
||||
],
|
||||
ignorePatterns: ['src/parser/*.ts', 'scripts/update-registry.js'],
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
|
||||
@@ -19,8 +19,6 @@ const config: Config.InitialOptions = {
|
||||
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^@signozhq/icons$':
|
||||
'<rootDir>/node_modules/@signozhq/icons/dist/index.esm.js',
|
||||
'^react-syntax-highlighter/dist/esm/(.*)$':
|
||||
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',
|
||||
},
|
||||
globals: {
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
"commitlint": "commitlint --edit $1",
|
||||
"test": "jest",
|
||||
"test:changedsince": "jest --changedSince=main --coverage --silent",
|
||||
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
|
||||
"generate:permissions-type": "node scripts/generate-permissions-type.js"
|
||||
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.15.0"
|
||||
@@ -215,7 +214,7 @@
|
||||
"@types/react-redux": "^7.1.11",
|
||||
"@types/react-resizable": "3.0.3",
|
||||
"@types/react-router-dom": "^5.1.6",
|
||||
"@types/react-syntax-highlighter": "15.5.13",
|
||||
"@types/react-syntax-highlighter": "15.5.7",
|
||||
"@types/redux-mock-store": "1.0.4",
|
||||
"@types/styled-components": "^5.1.4",
|
||||
"@types/uuid": "^8.3.1",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Extracts unique fenced code block language identifiers from all .md files under frontend/src/
|
||||
# Usage: bash frontend/scripts/extract-md-languages.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SRC_DIR="$SCRIPT_DIR/../src"
|
||||
|
||||
grep -roh '```[a-zA-Z0-9_+-]*' "$SRC_DIR" --include='*.md' \
|
||||
| sed 's/^```//' \
|
||||
| grep -v '^$' \
|
||||
| sort -u
|
||||
@@ -1,199 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
const axios = require('axios');
|
||||
|
||||
const PERMISSIONS_TYPE_FILE = path.join(
|
||||
__dirname,
|
||||
'../src/hooks/useAuthZ/permissions.type.ts',
|
||||
);
|
||||
|
||||
const SIGNOZ_INTEGRATION_IMAGE = 'signoz:integration';
|
||||
const LOCAL_BACKEND_URL = 'http://localhost:8080';
|
||||
|
||||
function log(message) {
|
||||
console.log(`[generate-permissions-type] ${message}`);
|
||||
}
|
||||
|
||||
function getBackendUrlFromDocker() {
|
||||
try {
|
||||
const output = execSync(
|
||||
`docker ps --filter "ancestor=${SIGNOZ_INTEGRATION_IMAGE}" --format "{{.Ports}}"`,
|
||||
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
).trim();
|
||||
|
||||
if (!output) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const portMatch = output.match(/0\.0\.0\.0:(\d+)->8080\/tcp/);
|
||||
if (portMatch) {
|
||||
return `http://localhost:${portMatch[1]}`;
|
||||
}
|
||||
|
||||
const ipv6Match = output.match(/:::(\d+)->8080\/tcp/);
|
||||
if (ipv6Match) {
|
||||
return `http://localhost:${ipv6Match[1]}`;
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Warning: Could not get port from docker: ${err.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function checkBackendHealth(url, maxAttempts = 3, delayMs = 1000) {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await axios.get(`${url}/api/v1/health`, {
|
||||
timeout: 5000,
|
||||
validateStatus: (status) => status === 200,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function discoverBackendUrl() {
|
||||
const dockerUrl = getBackendUrlFromDocker();
|
||||
if (dockerUrl) {
|
||||
log(`Found ${SIGNOZ_INTEGRATION_IMAGE} container, trying ${dockerUrl}...`);
|
||||
if (await checkBackendHealth(dockerUrl)) {
|
||||
log(`Backend found at ${dockerUrl} (from py-test-setup)`);
|
||||
return dockerUrl;
|
||||
}
|
||||
log(`Backend at ${dockerUrl} is not responding`);
|
||||
}
|
||||
|
||||
log(`Trying local backend at ${LOCAL_BACKEND_URL}...`);
|
||||
if (await checkBackendHealth(LOCAL_BACKEND_URL)) {
|
||||
log(`Backend found at ${LOCAL_BACKEND_URL}`);
|
||||
return LOCAL_BACKEND_URL;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchResources(backendUrl) {
|
||||
log('Fetching resources from API...');
|
||||
const resourcesUrl = `${backendUrl}/api/v1/authz/resources`;
|
||||
|
||||
const { data: response } = await axios.get(resourcesUrl);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function transformResponse(apiResponse) {
|
||||
if (!apiResponse.data) {
|
||||
throw new Error('Invalid API response: missing data field');
|
||||
}
|
||||
|
||||
const { resources, relations } = apiResponse.data;
|
||||
|
||||
return {
|
||||
status: apiResponse.status || 'success',
|
||||
data: {
|
||||
resources: resources,
|
||||
relations: relations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generateTypeScriptFile(data) {
|
||||
const resourcesStr = data.data.resources
|
||||
.map(
|
||||
(r) =>
|
||||
`\t\t\t{\n\t\t\t\tname: '${r.name}',\n\t\t\t\ttype: '${r.type}',\n\t\t\t}`,
|
||||
)
|
||||
.join(',\n');
|
||||
|
||||
const relationsStr = Object.entries(data.data.relations)
|
||||
.map(
|
||||
([type, relations]) =>
|
||||
`\t\t\t${type}: [${relations.map((r) => `'${r}'`).join(', ')}]`,
|
||||
)
|
||||
.join(',\n');
|
||||
|
||||
return `// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY scripts/generate-permissions-type
|
||||
export default {
|
||||
\tstatus: '${data.status}',
|
||||
\tdata: {
|
||||
\t\tresources: [
|
||||
${resourcesStr}
|
||||
\t\t],
|
||||
\t\trelations: {
|
||||
${relationsStr}
|
||||
\t\t},
|
||||
\t},
|
||||
} as const;
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
log('Starting permissions type generation...');
|
||||
|
||||
const backendUrl = await discoverBackendUrl();
|
||||
|
||||
if (!backendUrl) {
|
||||
console.error('\n' + '='.repeat(80));
|
||||
console.error('ERROR: No running SigNoz backend found!');
|
||||
console.error('='.repeat(80));
|
||||
console.error(
|
||||
'\nThe permissions type generator requires a running SigNoz backend.',
|
||||
);
|
||||
console.error('\nFor local development, start the backend with:');
|
||||
console.error(' make go-run-enterprise');
|
||||
console.error(
|
||||
'\nFor CI or integration testing, start the test environment with:',
|
||||
);
|
||||
console.error(' make py-test-setup');
|
||||
console.error(
|
||||
'\nIf running in CI and seeing this error, check that the py-test-setup',
|
||||
);
|
||||
console.error('step completed successfully before this step runs.');
|
||||
console.error('='.repeat(80) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log('Fetching resources...');
|
||||
const apiResponse = await fetchResources(backendUrl);
|
||||
|
||||
log('Transforming response...');
|
||||
const transformed = transformResponse(apiResponse);
|
||||
|
||||
log('Generating TypeScript file...');
|
||||
const content = generateTypeScriptFile(transformed);
|
||||
|
||||
log(`Writing to ${PERMISSIONS_TYPE_FILE}...`);
|
||||
fs.writeFileSync(PERMISSIONS_TYPE_FILE, content, 'utf8');
|
||||
|
||||
const rootDir = path.join(__dirname, '../..');
|
||||
const relativePath = path.relative(
|
||||
path.join(rootDir, 'frontend'),
|
||||
PERMISSIONS_TYPE_FILE,
|
||||
);
|
||||
log('Linting generated file...');
|
||||
execSync(`cd frontend && yarn eslint --fix ${relativePath}`, {
|
||||
cwd: rootDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
log('Successfully generated permissions.type.ts');
|
||||
} catch (error) {
|
||||
log(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Validates that all fenced code block languages used in .md files are registered
|
||||
# in the syntax highlighter.
|
||||
# Usage: bash frontend/scripts/validate-md-languages.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SYNTAX_HIGHLIGHTER="$SCRIPT_DIR/../src/components/MarkdownRenderer/syntaxHighlighter.ts"
|
||||
|
||||
# Get all languages used in .md files
|
||||
md_languages=$("$SCRIPT_DIR/extract-md-languages.sh")
|
||||
|
||||
# Get all registered languages from syntaxHighlighter.ts
|
||||
registered_languages=$(grep -oP "registerLanguage\('\K[^']+" "$SYNTAX_HIGHLIGHTER" | sort -u)
|
||||
|
||||
missing_languages=()
|
||||
|
||||
for lang in $md_languages; do
|
||||
if ! echo "$registered_languages" | grep -qx "$lang"; then
|
||||
missing_languages+=("$lang")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing_languages[@]} -gt 0 ]; then
|
||||
echo "Error: The following languages are used in .md files but not registered in syntaxHighlighter.ts:"
|
||||
for lang in "${missing_languages[@]}"; do
|
||||
echo " - $lang"
|
||||
done
|
||||
echo ""
|
||||
echo "Please add them to: frontend/src/components/MarkdownRenderer/syntaxHighlighter.ts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All markdown code block languages are registered in syntaxHighlighter.ts"
|
||||
@@ -1,973 +0,0 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
|
||||
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type {
|
||||
CreateServiceAccount201,
|
||||
CreateServiceAccountKey201,
|
||||
CreateServiceAccountKeyPathParameters,
|
||||
DeleteServiceAccountPathParameters,
|
||||
GetServiceAccount200,
|
||||
GetServiceAccountPathParameters,
|
||||
ListServiceAccountKeys200,
|
||||
ListServiceAccountKeysPathParameters,
|
||||
ListServiceAccounts200,
|
||||
RenderErrorResponseDTO,
|
||||
RevokeServiceAccountKeyPathParameters,
|
||||
ServiceaccounttypesPostableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesPostableServiceAccountDTO,
|
||||
ServiceaccounttypesUpdatableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesUpdatableServiceAccountDTO,
|
||||
ServiceaccounttypesUpdatableServiceAccountStatusDTO,
|
||||
UpdateServiceAccountKeyPathParameters,
|
||||
UpdateServiceAccountPathParameters,
|
||||
UpdateServiceAccountStatusPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
type AwaitedInput<T> = PromiseLike<T> | T;
|
||||
|
||||
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
|
||||
|
||||
/**
|
||||
* This endpoint lists the service accounts for an organisation
|
||||
* @summary List service accounts
|
||||
*/
|
||||
export const listServiceAccounts = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListServiceAccounts200>({
|
||||
url: `/api/v1/service_accounts`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListServiceAccountsQueryKey = () => {
|
||||
return [`/api/v1/service_accounts`] as const;
|
||||
};
|
||||
|
||||
export const getListServiceAccountsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listServiceAccounts>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServiceAccounts>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListServiceAccountsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listServiceAccounts>>
|
||||
> = ({ signal }) => listServiceAccounts(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServiceAccounts>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListServiceAccountsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listServiceAccounts>>
|
||||
>;
|
||||
export type ListServiceAccountsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List service accounts
|
||||
*/
|
||||
|
||||
export function useListServiceAccounts<
|
||||
TData = Awaited<ReturnType<typeof listServiceAccounts>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServiceAccounts>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListServiceAccountsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List service accounts
|
||||
*/
|
||||
export const invalidateListServiceAccounts = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListServiceAccountsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a service account
|
||||
* @summary Create service account
|
||||
*/
|
||||
export const createServiceAccount = (
|
||||
serviceaccounttypesPostableServiceAccountDTO: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccount201>({
|
||||
url: `/api/v1/service_accounts`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesPostableServiceAccountDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccount>>,
|
||||
TError,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccount>>,
|
||||
TError,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccount'];
|
||||
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 createServiceAccount>>,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createServiceAccount(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccount>>
|
||||
>;
|
||||
export type CreateServiceAccountMutationBody = BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
export type CreateServiceAccountMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create service account
|
||||
*/
|
||||
export const useCreateServiceAccount = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccount>>,
|
||||
TError,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccount>>,
|
||||
TError,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateServiceAccountMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint deletes an existing service account
|
||||
* @summary Deletes a service account
|
||||
*/
|
||||
export const deleteServiceAccount = ({
|
||||
id,
|
||||
}: DeleteServiceAccountPathParameters) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceAccountMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccount>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccount>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteServiceAccount'];
|
||||
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 deleteServiceAccount>>,
|
||||
{ pathParams: DeleteServiceAccountPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteServiceAccount(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccount>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceAccountMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Deletes a service account
|
||||
*/
|
||||
export const useDeleteServiceAccount = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccount>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteServiceAccount>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getDeleteServiceAccountMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint gets an existing service account
|
||||
* @summary Gets a service account
|
||||
*/
|
||||
export const getServiceAccount = (
|
||||
{ id }: GetServiceAccountPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetServiceAccount200>({
|
||||
url: `/api/v1/service_accounts/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetServiceAccountQueryKey = ({
|
||||
id,
|
||||
}: GetServiceAccountPathParameters) => {
|
||||
return [`/api/v1/service_accounts/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetServiceAccountQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getServiceAccount>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ id }: GetServiceAccountPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccount>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetServiceAccountQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getServiceAccount>>
|
||||
> = ({ signal }) => getServiceAccount({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccount>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetServiceAccountQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getServiceAccount>>
|
||||
>;
|
||||
export type GetServiceAccountQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Gets a service account
|
||||
*/
|
||||
|
||||
export function useGetServiceAccount<
|
||||
TData = Awaited<ReturnType<typeof getServiceAccount>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ id }: GetServiceAccountPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccount>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetServiceAccountQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Gets a service account
|
||||
*/
|
||||
export const invalidateGetServiceAccount = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetServiceAccountPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetServiceAccountQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint updates an existing service account
|
||||
* @summary Updates a service account
|
||||
*/
|
||||
export const updateServiceAccount = (
|
||||
{ id }: UpdateServiceAccountPathParameters,
|
||||
serviceaccounttypesUpdatableServiceAccountDTO: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesUpdatableServiceAccountDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateServiceAccountMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceAccount>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceAccount>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateServiceAccount'];
|
||||
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 updateServiceAccount>>,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateServiceAccount(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateServiceAccountMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateServiceAccount>>
|
||||
>;
|
||||
export type UpdateServiceAccountMutationBody = BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
export type UpdateServiceAccountMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Updates a service account
|
||||
*/
|
||||
export const useUpdateServiceAccount = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceAccount>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateServiceAccount>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateServiceAccountMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint lists the service account keys
|
||||
* @summary List service account keys
|
||||
*/
|
||||
export const listServiceAccountKeys = (
|
||||
{ id }: ListServiceAccountKeysPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListServiceAccountKeys200>({
|
||||
url: `/api/v1/service_accounts/${id}/keys`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListServiceAccountKeysQueryKey = ({
|
||||
id,
|
||||
}: ListServiceAccountKeysPathParameters) => {
|
||||
return [`/api/v1/service_accounts/${id}/keys`] as const;
|
||||
};
|
||||
|
||||
export const getListServiceAccountKeysQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listServiceAccountKeys>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ id }: ListServiceAccountKeysPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServiceAccountKeys>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListServiceAccountKeysQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listServiceAccountKeys>>
|
||||
> = ({ signal }) => listServiceAccountKeys({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServiceAccountKeys>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListServiceAccountKeysQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listServiceAccountKeys>>
|
||||
>;
|
||||
export type ListServiceAccountKeysQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List service account keys
|
||||
*/
|
||||
|
||||
export function useListServiceAccountKeys<
|
||||
TData = Awaited<ReturnType<typeof listServiceAccountKeys>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ id }: ListServiceAccountKeysPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServiceAccountKeys>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListServiceAccountKeysQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List service account keys
|
||||
*/
|
||||
export const invalidateListServiceAccountKeys = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: ListServiceAccountKeysPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListServiceAccountKeysQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a service account key
|
||||
* @summary Create a service account key
|
||||
*/
|
||||
export const createServiceAccountKey = (
|
||||
{ id }: CreateServiceAccountKeyPathParameters,
|
||||
serviceaccounttypesPostableFactorAPIKeyDTO: BodyType<ServiceaccounttypesPostableFactorAPIKeyDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountKey201>({
|
||||
url: `/api/v1/service_accounts/${id}/keys`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesPostableFactorAPIKeyDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountKeyMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountKeyPathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableFactorAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountKeyPathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableFactorAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccountKey'];
|
||||
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 createServiceAccountKey>>,
|
||||
{
|
||||
pathParams: CreateServiceAccountKeyPathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableFactorAPIKeyDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return createServiceAccountKey(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountKeyMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountKey>>
|
||||
>;
|
||||
export type CreateServiceAccountKeyMutationBody = BodyType<ServiceaccounttypesPostableFactorAPIKeyDTO>;
|
||||
export type CreateServiceAccountKeyMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create a service account key
|
||||
*/
|
||||
export const useCreateServiceAccountKey = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountKeyPathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableFactorAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccountKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountKeyPathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableFactorAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateServiceAccountKeyMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes an existing service account key
|
||||
* @summary Revoke a service account key
|
||||
*/
|
||||
export const revokeServiceAccountKey = ({
|
||||
id,
|
||||
fid,
|
||||
}: RevokeServiceAccountKeyPathParameters) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}/keys/${fid}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getRevokeServiceAccountKeyMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof revokeServiceAccountKey>>,
|
||||
TError,
|
||||
{ pathParams: RevokeServiceAccountKeyPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof revokeServiceAccountKey>>,
|
||||
TError,
|
||||
{ pathParams: RevokeServiceAccountKeyPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['revokeServiceAccountKey'];
|
||||
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 revokeServiceAccountKey>>,
|
||||
{ pathParams: RevokeServiceAccountKeyPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return revokeServiceAccountKey(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type RevokeServiceAccountKeyMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof revokeServiceAccountKey>>
|
||||
>;
|
||||
|
||||
export type RevokeServiceAccountKeyMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Revoke a service account key
|
||||
*/
|
||||
export const useRevokeServiceAccountKey = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof revokeServiceAccountKey>>,
|
||||
TError,
|
||||
{ pathParams: RevokeServiceAccountKeyPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof revokeServiceAccountKey>>,
|
||||
TError,
|
||||
{ pathParams: RevokeServiceAccountKeyPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getRevokeServiceAccountKeyMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint updates an existing service account key
|
||||
* @summary Updates a service account key
|
||||
*/
|
||||
export const updateServiceAccountKey = (
|
||||
{ id, fid }: UpdateServiceAccountKeyPathParameters,
|
||||
serviceaccounttypesUpdatableFactorAPIKeyDTO: BodyType<ServiceaccounttypesUpdatableFactorAPIKeyDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}/keys/${fid}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesUpdatableFactorAPIKeyDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateServiceAccountKeyMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceAccountKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountKeyPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableFactorAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceAccountKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountKeyPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableFactorAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateServiceAccountKey'];
|
||||
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 updateServiceAccountKey>>,
|
||||
{
|
||||
pathParams: UpdateServiceAccountKeyPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableFactorAPIKeyDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateServiceAccountKey(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateServiceAccountKeyMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateServiceAccountKey>>
|
||||
>;
|
||||
export type UpdateServiceAccountKeyMutationBody = BodyType<ServiceaccounttypesUpdatableFactorAPIKeyDTO>;
|
||||
export type UpdateServiceAccountKeyMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Updates a service account key
|
||||
*/
|
||||
export const useUpdateServiceAccountKey = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceAccountKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountKeyPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableFactorAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateServiceAccountKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountKeyPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableFactorAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateServiceAccountKeyMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint updates an existing service account status
|
||||
* @summary Updates a service account status
|
||||
*/
|
||||
export const updateServiceAccountStatus = (
|
||||
{ id }: UpdateServiceAccountStatusPathParameters,
|
||||
serviceaccounttypesUpdatableServiceAccountStatusDTO: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}/status`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesUpdatableServiceAccountStatusDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateServiceAccountStatusMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateServiceAccountStatus'];
|
||||
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 updateServiceAccountStatus>>,
|
||||
{
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateServiceAccountStatus(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateServiceAccountStatusMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>
|
||||
>;
|
||||
export type UpdateServiceAccountStatusMutationBody = BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
export type UpdateServiceAccountStatusMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Updates a service account status
|
||||
*/
|
||||
export const useUpdateServiceAccountStatus = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateServiceAccountStatusMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
@@ -2090,154 +2090,6 @@ export interface RoletypesRoleDTO {
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
last_used: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
service_account_id: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesPostableFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesPostableServiceAccountDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgID: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
roles: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesUpdatableServiceAccountDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesUpdatableServiceAccountStatusDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
}
|
||||
|
||||
export enum TelemetrytypesFieldContextDTO {
|
||||
metric = 'metric',
|
||||
log = 'log',
|
||||
@@ -3198,78 +3050,6 @@ export type PatchObjectsPathParameters = {
|
||||
id: string;
|
||||
relation: string;
|
||||
};
|
||||
export type ListServiceAccounts200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: ServiceaccounttypesServiceAccountDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccount201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccountPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccount200 = {
|
||||
data: ServiceaccounttypesServiceAccountDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateServiceAccountPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListServiceAccountKeysPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListServiceAccountKeys200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: ServiceaccounttypesFactorAPIKeyDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountKeyPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CreateServiceAccountKey201 = {
|
||||
data: ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type RevokeServiceAccountKeyPathParameters = {
|
||||
id: string;
|
||||
fid: string;
|
||||
};
|
||||
export type UpdateServiceAccountKeyPathParameters = {
|
||||
id: string;
|
||||
fid: string;
|
||||
};
|
||||
export type UpdateServiceAccountStatusPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListUsers200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -3451,11 +3231,6 @@ export type ListMetricsParams = {
|
||||
* @description undefined
|
||||
*/
|
||||
searchText?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type ListMetrics200 = {
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { BrandedPermission } from 'hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'hooks/useAuthZ/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { GuardAuthZ } from './GuardAuthZ';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL || '';
|
||||
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
|
||||
|
||||
function authzMockResponse(
|
||||
payload: AuthtypesTransactionDTO[],
|
||||
authorizedByIndex: boolean[],
|
||||
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
|
||||
return {
|
||||
data: payload.map((txn, i) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: authorizedByIndex[i] ?? false,
|
||||
})),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
describe('GuardAuthZ', () => {
|
||||
const TestChild = (): ReactElement => <div>Protected Content</div>;
|
||||
const LoadingFallback = (): ReactElement => <div>Loading...</div>;
|
||||
const ErrorFallback = (error: Error): ReactElement => (
|
||||
<div>Error occurred: {error.message}</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="dashboard:*">
|
||||
<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="dashboard:*"
|
||||
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="dashboard:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render fallbackOnError when API error occurs', async () => {
|
||||
const errorMessage = 'Internal Server Error';
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: errorMessage }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object="dashboard:*"
|
||||
fallbackOnError={ErrorFallback}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Error occurred:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass error object to fallbackOnError function', async () => {
|
||||
const errorMessage = 'Network request failed';
|
||||
let receivedError: Error | null = null;
|
||||
|
||||
const errorFallbackWithCapture = (error: Error): ReactElement => {
|
||||
receivedError = error;
|
||||
return <div>Captured error: {error.message}</div>;
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: errorMessage }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object="dashboard:*"
|
||||
fallbackOnError={errorFallbackWithCapture}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(receivedError).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(receivedError).toBeInstanceOf(Error);
|
||||
expect(screen.getByText(/Captured error:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when error occurs and no fallbackOnError provided', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="read" object="dashboard:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
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="dashboard: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="dashboard: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="dashboard:*">
|
||||
<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', 'dashboard: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="dashboard: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="dashboard:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<GuardAuthZ relation="delete" object="dashboard:456">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
} from 'hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { buildPermission } from 'hooks/useAuthZ/utils';
|
||||
|
||||
export type GuardAuthZProps<R extends AuthZRelation> = {
|
||||
children: ReactElement;
|
||||
relation: R;
|
||||
object: AuthZObject<R>;
|
||||
fallbackOnLoading?: JSX.Element;
|
||||
fallbackOnError?: (error: Error) => 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?.(error) ?? null;
|
||||
}
|
||||
|
||||
if (!permissions?.[permission]?.isGranted) {
|
||||
return (
|
||||
fallbackOnNoPermissions?.({
|
||||
requiredPermissionName: permission,
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { CodeProps } from 'react-markdown/lib/ast-to-react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { a11yDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
||||
import CodeCopyBtn from './CodeCopyBtn/CodeCopyBtn';
|
||||
import SyntaxHighlighter, { a11yDark } from './syntaxHighlighter';
|
||||
|
||||
interface LinkProps {
|
||||
href: string;
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash';
|
||||
import docker from 'react-syntax-highlighter/dist/esm/languages/prism/docker';
|
||||
import elixir from 'react-syntax-highlighter/dist/esm/languages/prism/elixir';
|
||||
import go from 'react-syntax-highlighter/dist/esm/languages/prism/go';
|
||||
import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript';
|
||||
import json from 'react-syntax-highlighter/dist/esm/languages/prism/json';
|
||||
import jsx from 'react-syntax-highlighter/dist/esm/languages/prism/jsx';
|
||||
import rust from 'react-syntax-highlighter/dist/esm/languages/prism/rust';
|
||||
import swift from 'react-syntax-highlighter/dist/esm/languages/prism/swift';
|
||||
import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
|
||||
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
|
||||
import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml';
|
||||
import a11yDark from 'react-syntax-highlighter/dist/esm/styles/prism/a11y-dark';
|
||||
|
||||
SyntaxHighlighter.registerLanguage('bash', bash);
|
||||
SyntaxHighlighter.registerLanguage('docker', docker);
|
||||
SyntaxHighlighter.registerLanguage('dockerfile', docker);
|
||||
SyntaxHighlighter.registerLanguage('elixir', elixir);
|
||||
SyntaxHighlighter.registerLanguage('go', go);
|
||||
SyntaxHighlighter.registerLanguage('javascript', javascript);
|
||||
SyntaxHighlighter.registerLanguage('js', javascript);
|
||||
SyntaxHighlighter.registerLanguage('json', json);
|
||||
SyntaxHighlighter.registerLanguage('jsx', jsx);
|
||||
SyntaxHighlighter.registerLanguage('rust', rust);
|
||||
SyntaxHighlighter.registerLanguage('swift', swift);
|
||||
SyntaxHighlighter.registerLanguage('ts', typescript);
|
||||
SyntaxHighlighter.registerLanguage('tsx', tsx);
|
||||
SyntaxHighlighter.registerLanguage('typescript', typescript);
|
||||
SyntaxHighlighter.registerLanguage('yaml', yaml);
|
||||
SyntaxHighlighter.registerLanguage('yml', yaml);
|
||||
|
||||
export default SyntaxHighlighter;
|
||||
export { a11yDark };
|
||||
@@ -60,30 +60,11 @@
|
||||
gap: 8px;
|
||||
|
||||
margin-left: 108px;
|
||||
position: relative;
|
||||
|
||||
/* Vertical dashed line connecting query elements */
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
#1d212d,
|
||||
#1d212d 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
|
||||
.code-mirror-where-clause,
|
||||
.query-aggregation-container,
|
||||
.query-add-ons,
|
||||
.metrics-aggregation-section-content,
|
||||
.metrics-container {
|
||||
.metrics-aggregation-section-content {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
@@ -121,10 +102,6 @@
|
||||
.qb-elements-container {
|
||||
margin-left: 0px;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.code-mirror-where-clause,
|
||||
.query-aggregation-container,
|
||||
.query-add-ons,
|
||||
@@ -356,7 +333,28 @@
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
content: '';
|
||||
height: 120px;
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 31px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
#1d212d,
|
||||
#1d212d 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
left: 15px;
|
||||
}
|
||||
|
||||
&.has-trace-operator {
|
||||
&::before {
|
||||
height: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,21 +462,10 @@
|
||||
|
||||
.qb-content-section {
|
||||
.qb-elements-container {
|
||||
&::after {
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--bg-vanilla-300),
|
||||
var(--bg-vanilla-300) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
|
||||
.code-mirror-where-clause,
|
||||
.query-aggregation-container,
|
||||
.query-add-ons,
|
||||
.metrics-aggregation-section-content,
|
||||
.metrics-container {
|
||||
.metrics-aggregation-section-content {
|
||||
&::before {
|
||||
border-left: 6px dotted var(--bg-vanilla-300);
|
||||
}
|
||||
@@ -542,6 +529,18 @@
|
||||
|
||||
.qb-entity-options {
|
||||
.options {
|
||||
.query-name {
|
||||
&::before {
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--bg-vanilla-300),
|
||||
var(--bg-vanilla-300) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.formula-name {
|
||||
&::before {
|
||||
background: repeating-linear-gradient(
|
||||
|
||||
@@ -207,7 +207,6 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isListViewPanel={isListViewPanel}
|
||||
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
queriesCount={1}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryMeterWithType,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { MetricNameSelector } from 'container/QueryBuilder/filters';
|
||||
import { AggregatorFilter } from 'container/QueryBuilder/filters';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
@@ -43,12 +44,21 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
signalSourceChangeEnabled: boolean;
|
||||
savePreviousQuery: boolean;
|
||||
}): JSX.Element {
|
||||
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
|
||||
|
||||
const { handleChangeAggregatorAttribute } = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
entityVersion: version,
|
||||
});
|
||||
|
||||
const handleAggregatorAttributeChange = useCallback(
|
||||
(value: BaseAutocompleteData, isEditMode?: boolean) => {
|
||||
handleChangeAggregatorAttribute(value, isEditMode, attributeKeys || []);
|
||||
},
|
||||
[handleChangeAggregatorAttribute, attributeKeys],
|
||||
);
|
||||
|
||||
const {
|
||||
updateAllQueriesOperators,
|
||||
handleSetQueryData,
|
||||
@@ -154,10 +164,12 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
/>
|
||||
)}
|
||||
|
||||
<MetricNameSelector
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
<AggregatorFilter
|
||||
onChange={handleAggregatorAttributeChange}
|
||||
query={query}
|
||||
index={index}
|
||||
signalSource={signalSource || ''}
|
||||
setAttributeKeys={setAttributeKeys}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -202,8 +202,8 @@ function QueryAddOns({
|
||||
} else {
|
||||
filteredAddOns = Object.values(ADD_ONS);
|
||||
|
||||
// Filter out group_by for metrics data source
|
||||
if (query.dataSource === DataSource.METRICS) {
|
||||
// Filter out group_by for metrics data source (handled in MetricsAggregateSection)
|
||||
filteredAddOns = filteredAddOns.filter(
|
||||
(addOn) => addOn.key !== ADD_ONS_KEYS.GROUP_BY,
|
||||
);
|
||||
|
||||
@@ -43,7 +43,6 @@ jest.mock(
|
||||
);
|
||||
jest.mock('container/QueryBuilder/filters', () => ({
|
||||
AggregatorFilter: (): JSX.Element => <div />,
|
||||
MetricNameSelector: (): JSX.Element => <div />,
|
||||
}));
|
||||
// Mock hooks
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
|
||||
@@ -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,472 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import type { RouteComponentProps } from 'react-router-dom';
|
||||
import {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { createGuardedRoute } from './createGuardedRoute';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL || '';
|
||||
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
|
||||
|
||||
function authzMockResponse(
|
||||
payload: AuthtypesTransactionDTO[],
|
||||
authorizedByIndex: boolean[],
|
||||
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
|
||||
return {
|
||||
data: payload.map((txn, i) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: authorizedByIndex[i] ?? false,
|
||||
})),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
'dashboard:*',
|
||||
);
|
||||
|
||||
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',
|
||||
'dashboard:{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: {
|
||||
name: txn.object.resource.name,
|
||||
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',
|
||||
'dashboard:{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',
|
||||
'dashboard:{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',
|
||||
'dashboard:*',
|
||||
);
|
||||
|
||||
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 error fallback when API error occurs', 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',
|
||||
'dashboard:*',
|
||||
);
|
||||
|
||||
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(/Something went wrong/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText('Test Component: test-value'),
|
||||
).not.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',
|
||||
'dashboard:{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(/permission to view/i);
|
||||
});
|
||||
|
||||
expect(screen.getByText('update')).toBeInTheDocument();
|
||||
expect(screen.getByText('dashboard: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',
|
||||
'dashboard:*',
|
||||
);
|
||||
|
||||
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 name = obj?.resource?.name;
|
||||
const selector = obj?.selector ?? '*';
|
||||
const objectStr =
|
||||
obj?.resource?.type === 'metaresources' ? name : `${name}:${selector}`;
|
||||
requestedObjects.push(objectStr ?? '');
|
||||
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'read',
|
||||
'dashboard:{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('dashboard: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('dashboard: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',
|
||||
'dashboard:{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,73 +0,0 @@
|
||||
import { ComponentType, ReactElement, useMemo } from 'react';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
} from 'hooks/useAuthZ/types';
|
||||
import { parsePermission } from 'hooks/useAuthZ/utils';
|
||||
|
||||
import ErrorBoundaryFallback from '../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import AppLoading from '../AppLoading/AppLoading';
|
||||
import { GuardAuthZ } from '../GuardAuthZ/GuardAuthZ';
|
||||
|
||||
import './createGuardedRoute.styles.scss';
|
||||
|
||||
const onErrorFallback = (): JSX.Element => <ErrorBoundaryFallback />;
|
||||
|
||||
function OnNoPermissionsFallback(response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement {
|
||||
const { relation, object } = parsePermission(response.requiredPermissionName);
|
||||
|
||||
return (
|
||||
<div className="guard-authz-error-no-authz">
|
||||
<div className="guard-authz-error-no-authz-content">
|
||||
<img src="/Icons/no-data.svg" alt="No permission" />
|
||||
<h3>Uh-oh! You don’t have permission to view this page.</h3>
|
||||
<p>
|
||||
You need the following permission to view this page:
|
||||
<br />
|
||||
Relation: <span>{relation}</span>
|
||||
<br />
|
||||
Object: <span>{object}</span>
|
||||
<br />
|
||||
Ask your SigNoz administrator to grant access.
|
||||
</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 />}
|
||||
fallbackOnError={onErrorFallback}
|
||||
fallbackOnNoPermissions={(response): ReactElement => (
|
||||
<OnNoPermissionsFallback {...response} />
|
||||
)}
|
||||
>
|
||||
<Component {...props} />
|
||||
</GuardAuthZ>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export enum LOCALSTORAGE {
|
||||
DONT_SHOW_SLOW_API_WARNING = 'DONT_SHOW_SLOW_API_WARNING',
|
||||
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
|
||||
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
|
||||
BANNER_DISMISSED = 'BANNER_DISMISSED',
|
||||
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
|
||||
FUNNEL_STEPS = 'FUNNEL_STEPS',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// ** Helpers
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
|
||||
@@ -178,7 +177,7 @@ export const initialQueryBuilderFormValues: IBuilderQuery = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
timeAggregation: MetricAggregateOperator.COUNT,
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -226,7 +225,7 @@ export const initialQueryBuilderFormMeterValues: IBuilderQuery = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: MeterAggregateOperator.AVG,
|
||||
timeAggregation: MeterAggregateOperator.COUNT,
|
||||
spaceAggregation: MeterAggregateOperator.SUM,
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -372,31 +371,6 @@ export enum ATTRIBUTE_TYPES {
|
||||
EXPONENTIAL_HISTOGRAM = 'ExponentialHistogram',
|
||||
}
|
||||
|
||||
const METRIC_TYPE_TO_ATTRIBUTE_TYPE: Record<
|
||||
MetrictypesTypeDTO,
|
||||
ATTRIBUTE_TYPES
|
||||
> = {
|
||||
[MetrictypesTypeDTO.sum]: ATTRIBUTE_TYPES.SUM,
|
||||
[MetrictypesTypeDTO.gauge]: ATTRIBUTE_TYPES.GAUGE,
|
||||
[MetrictypesTypeDTO.histogram]: ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
[MetrictypesTypeDTO.summary]: ATTRIBUTE_TYPES.GAUGE,
|
||||
[MetrictypesTypeDTO.exponentialhistogram]:
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
};
|
||||
|
||||
export function toAttributeType(
|
||||
metricType: MetrictypesTypeDTO | undefined,
|
||||
isMonotonic?: boolean,
|
||||
): ATTRIBUTE_TYPES | '' {
|
||||
if (!metricType) {
|
||||
return '';
|
||||
}
|
||||
if (metricType === MetrictypesTypeDTO.sum && isMonotonic === false) {
|
||||
return ATTRIBUTE_TYPES.GAUGE;
|
||||
}
|
||||
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';
|
||||
}
|
||||
|
||||
export type IQueryBuilderState = 'search';
|
||||
|
||||
export const QUERY_BUILDER_SEARCH_VALUES = {
|
||||
|
||||
@@ -441,7 +441,7 @@ describe('Footer utils', () => {
|
||||
reduceTo: undefined,
|
||||
spaceAggregation: 'sum',
|
||||
temporality: undefined,
|
||||
timeAggregation: 'avg',
|
||||
timeAggregation: 'count',
|
||||
},
|
||||
],
|
||||
disabled: false,
|
||||
|
||||
@@ -25,6 +25,51 @@
|
||||
background: var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.home-container-banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 12px;
|
||||
width: 100%;
|
||||
background-color: var(--bg-robin-500);
|
||||
|
||||
.home-container-banner-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.home-container-banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
|
||||
.home-container-banner-link {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.home-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Compass, Dot, House, Plus, Wrench } from '@signozhq/icons';
|
||||
import { Button, Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import Header from 'components/Header/Header';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
@@ -15,8 +15,10 @@ import ROUTES from 'constants/routes';
|
||||
import { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils';
|
||||
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { CompassIcon, DotIcon, HomeIcon, Plus, Wrench, X } from 'lucide-react';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import * as motion from 'motion/react-client';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
@@ -49,6 +51,8 @@ export default function Home(): JSX.Element {
|
||||
const [updatingUserPreferences, setUpdatingUserPreferences] = useState(false);
|
||||
const [loadingUserPreferences, setLoadingUserPreferences] = useState(true);
|
||||
|
||||
const { isCommunityUser, isCommunityEnterpriseUser } = useGetTenantLicense();
|
||||
|
||||
const [checklistItems, setChecklistItems] = useState<ChecklistItem[]>(
|
||||
defaultChecklistItemsState,
|
||||
);
|
||||
@@ -57,6 +61,13 @@ export default function Home(): JSX.Element {
|
||||
false,
|
||||
);
|
||||
|
||||
const [isBannerDismissed, setIsBannerDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const bannerDismissed = localStorage.getItem(LOCALSTORAGE.BANNER_DISMISSED);
|
||||
setIsBannerDismissed(bannerDismissed === 'true');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const startTime = new Date(now.getTime() - homeInterval);
|
||||
@@ -287,13 +298,44 @@ export default function Home(): JSX.Element {
|
||||
logEvent('Homepage: Visited', {});
|
||||
}, []);
|
||||
|
||||
const hideBanner = (): void => {
|
||||
localStorage.setItem(LOCALSTORAGE.BANNER_DISMISSED, 'true');
|
||||
setIsBannerDismissed(true);
|
||||
};
|
||||
|
||||
const showBanner = useMemo(
|
||||
() => !isBannerDismissed && (isCommunityUser || isCommunityEnterpriseUser),
|
||||
[isBannerDismissed, isCommunityUser, isCommunityEnterpriseUser],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
<div className="sticky-header">
|
||||
{showBanner && (
|
||||
<div className="home-container-banner">
|
||||
<div className="home-container-banner-content">
|
||||
Big News: SigNoz Community Edition now available with SSO (Google OAuth)
|
||||
and API keys -
|
||||
<a
|
||||
href="https://signoz.io/blog/open-source-signoz-now-available-with-sso-and-api-keys/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="home-container-banner-link"
|
||||
>
|
||||
<i>read more</i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="home-container-banner-close">
|
||||
<X size={16} onClick={hideBanner} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Header
|
||||
leftComponent={
|
||||
<div className="home-header-left">
|
||||
<House size={14} /> Home
|
||||
<HomeIcon size={14} /> Home
|
||||
</div>
|
||||
}
|
||||
rightComponent={
|
||||
@@ -358,7 +400,7 @@ export default function Home(): JSX.Element {
|
||||
<div className="active-ingestion-card-content-container">
|
||||
<div className="active-ingestion-card-content">
|
||||
<div className="active-ingestion-card-content-icon">
|
||||
<Dot size={16} color={Color.BG_FOREST_500} />
|
||||
<DotIcon size={16} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
|
||||
<div className="active-ingestion-card-content-description">
|
||||
@@ -385,7 +427,7 @@ export default function Home(): JSX.Element {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Compass size={12} />
|
||||
<CompassIcon size={12} />
|
||||
Explore Logs
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,7 +441,7 @@ export default function Home(): JSX.Element {
|
||||
<div className="active-ingestion-card-content-container">
|
||||
<div className="active-ingestion-card-content">
|
||||
<div className="active-ingestion-card-content-icon">
|
||||
<Dot size={16} color={Color.BG_FOREST_500} />
|
||||
<DotIcon size={16} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
|
||||
<div className="active-ingestion-card-content-description">
|
||||
@@ -426,7 +468,7 @@ export default function Home(): JSX.Element {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Compass size={12} />
|
||||
<CompassIcon size={12} />
|
||||
Explore Traces
|
||||
</div>
|
||||
</div>
|
||||
@@ -440,7 +482,7 @@ export default function Home(): JSX.Element {
|
||||
<div className="active-ingestion-card-content-container">
|
||||
<div className="active-ingestion-card-content">
|
||||
<div className="active-ingestion-card-content-icon">
|
||||
<Dot size={16} color={Color.BG_FOREST_500} />
|
||||
<DotIcon size={16} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
|
||||
<div className="active-ingestion-card-content-description">
|
||||
@@ -467,7 +509,7 @@ export default function Home(): JSX.Element {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Compass size={12} />
|
||||
<CompassIcon size={12} />
|
||||
Explore Metrics
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { isAxiosError } from 'axios';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
@@ -116,34 +115,27 @@ function TimeSeries(): JSX.Element {
|
||||
setYAxisUnit(value);
|
||||
};
|
||||
|
||||
const hasMetricSelected = useMemo(
|
||||
() => currentQuery.builder.queryData.some((q) => q.aggregateAttribute?.key),
|
||||
[currentQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="meter-time-series-container">
|
||||
<BuilderUnitsFilter onChange={onUnitChangeHandler} yAxisUnit={yAxisUnit} />
|
||||
<div className="time-series-container">
|
||||
{!hasMetricSelected && <EmptyMetricsSearch />}
|
||||
{hasMetricSelected &&
|
||||
responseData.map((datapoint, index) => (
|
||||
<div
|
||||
className="time-series-view-panel"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
<TimeSeriesView
|
||||
isFilterApplied={false}
|
||||
isError={queries[index].isError}
|
||||
isLoading={queries[index].isLoading}
|
||||
data={datapoint}
|
||||
dataSource={DataSource.METRICS}
|
||||
yAxisUnit={yAxisUnit}
|
||||
panelType={PANEL_TYPES.BAR}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{responseData.map((datapoint, index) => (
|
||||
<div
|
||||
className="time-series-view-panel"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
<TimeSeriesView
|
||||
isFilterApplied={false}
|
||||
isError={queries[index].isError}
|
||||
isLoading={queries[index].isLoading}
|
||||
data={datapoint}
|
||||
dataSource={DataSource.METRICS}
|
||||
yAxisUnit={yAxisUnit}
|
||||
panelType={PANEL_TYPES.BAR}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import { Typography } from 'antd';
|
||||
import { Empty } from 'antd/lib';
|
||||
|
||||
interface EmptyMetricsSearchProps {
|
||||
hasQueryResult?: boolean;
|
||||
}
|
||||
|
||||
export default function EmptyMetricsSearch({
|
||||
hasQueryResult,
|
||||
}: EmptyMetricsSearchProps): JSX.Element {
|
||||
export default function EmptyMetricsSearch(): JSX.Element {
|
||||
return (
|
||||
<div className="empty-metrics-search">
|
||||
<Empty
|
||||
description={
|
||||
<Typography.Title level={5}>
|
||||
{hasQueryResult
|
||||
? 'No data'
|
||||
: 'Select a metric and run a query to see the results'}
|
||||
Please build and run a valid query to see the result
|
||||
</Typography.Title>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -69,7 +69,7 @@ function Explorer(): JSX.Element {
|
||||
!isMetricUnitsLoading &&
|
||||
!isMetricUnitsError &&
|
||||
units.length > 0 &&
|
||||
units.every((unit) => unit === units[0]),
|
||||
units.every((unit) => unit && unit === units[0]),
|
||||
[units, isMetricUnitsLoading, isMetricUnitsError],
|
||||
);
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import EmptyMetricsSearch from './EmptyMetricsSearch';
|
||||
import { TimeSeriesProps } from './types';
|
||||
import {
|
||||
buildUpdateMetricYAxisUnitPayload,
|
||||
@@ -210,7 +209,7 @@ function TimeSeries({
|
||||
{showSaveUnitButton && (
|
||||
<div className="save-unit-container">
|
||||
<Typography.Text>
|
||||
Set the selected unit as the metric unit?
|
||||
Save the selected unit for this metric?
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -230,71 +229,64 @@ function TimeSeries({
|
||||
'time-series-container': changeLayoutForOneChartPerQuery,
|
||||
})}
|
||||
>
|
||||
{metricNames.length === 0 && <EmptyMetricsSearch />}
|
||||
{metricNames.length > 0 &&
|
||||
responseData.map((datapoint, index) => {
|
||||
const isQueryDataItem = index < metricNames.length;
|
||||
const metricName = isQueryDataItem ? metricNames[index] : undefined;
|
||||
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
|
||||
{responseData.map((datapoint, index) => {
|
||||
const isQueryDataItem = index < metricNames.length;
|
||||
const metricName = isQueryDataItem ? metricNames[index] : undefined;
|
||||
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
|
||||
|
||||
// Show the no unit warning if -
|
||||
// 1. The metric query is not loading
|
||||
// 2. The metric units are not loading
|
||||
// 3. There are more than one metric
|
||||
// 4. The current metric unit is empty
|
||||
// 5. Is a queryData item
|
||||
const isMetricUnitEmpty =
|
||||
isQueryDataItem &&
|
||||
!queries[index].isLoading &&
|
||||
!isMetricUnitsLoading &&
|
||||
metricUnits.length > 1 &&
|
||||
!metricUnit &&
|
||||
metricName;
|
||||
// Show the no unit warning if -
|
||||
// 1. The metric query is not loading
|
||||
// 2. The metric units are not loading
|
||||
// 3. There are more than one metric
|
||||
// 4. The current metric unit is empty
|
||||
// 5. Is a queryData item
|
||||
const isMetricUnitEmpty =
|
||||
isQueryDataItem &&
|
||||
!queries[index].isLoading &&
|
||||
!isMetricUnitsLoading &&
|
||||
metricUnits.length > 1 &&
|
||||
!metricUnit &&
|
||||
metricName;
|
||||
|
||||
const currentYAxisUnit = yAxisUnit || metricUnit;
|
||||
const currentYAxisUnit = yAxisUnit || metricUnit;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="time-series-view"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
{isMetricUnitEmpty && metricName && (
|
||||
<Tooltip
|
||||
className="no-unit-warning"
|
||||
title={
|
||||
<Typography.Text>
|
||||
No unit is set for this metric. You can assign one from the{' '}
|
||||
<Typography.Link
|
||||
onClick={(): void => handleOpenMetricDetails(metricName)}
|
||||
>
|
||||
metric details
|
||||
</Typography.Link>{' '}
|
||||
page.
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
color={Color.BG_AMBER_400}
|
||||
role="img"
|
||||
aria-label="no unit warning"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<TimeSeriesView
|
||||
isFilterApplied={false}
|
||||
isError={queries[index].isError}
|
||||
isLoading={queries[index].isLoading || isMetricUnitsLoading}
|
||||
data={datapoint}
|
||||
yAxisUnit={currentYAxisUnit}
|
||||
dataSource={DataSource.METRICS}
|
||||
error={queries[index].error as APIError}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<div
|
||||
className="time-series-view"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
{isMetricUnitEmpty && metricName && (
|
||||
<Tooltip
|
||||
className="no-unit-warning"
|
||||
title={
|
||||
<Typography.Text>
|
||||
This metric does not have a unit. Please set one for it in the{' '}
|
||||
<Typography.Link
|
||||
onClick={(): void => handleOpenMetricDetails(metricName)}
|
||||
>
|
||||
metric details
|
||||
</Typography.Link>{' '}
|
||||
page.
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<AlertTriangle size={16} color={Color.BG_AMBER_400} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<TimeSeriesView
|
||||
isFilterApplied={false}
|
||||
isError={queries[index].isError}
|
||||
isLoading={queries[index].isLoading || isMetricUnitsLoading}
|
||||
data={datapoint}
|
||||
yAxisUnit={currentYAxisUnit}
|
||||
dataSource={DataSource.METRICS}
|
||||
error={queries[index].error as APIError}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import EmptyMetricsSearch from '../EmptyMetricsSearch';
|
||||
|
||||
describe('EmptyMetricsSearch', () => {
|
||||
it('shows select metric message when no query has been run', () => {
|
||||
render(<EmptyMetricsSearch />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Select a metric and run a query to see the results'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no data message when a query returned empty results', () => {
|
||||
render(<EmptyMetricsSearch hasQueryResult />);
|
||||
|
||||
expect(screen.getByText('No data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import * as useOptionsMenuHooks from 'container/OptionsMenu';
|
||||
import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard';
|
||||
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -157,6 +157,26 @@ describe('Explorer', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render Explorer query builder with metrics datasource selected', () => {
|
||||
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
|
||||
...mockUseQueryBuilderData,
|
||||
stagedQuery: initialQueriesMap[DataSource.TRACES],
|
||||
} as any);
|
||||
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
|
||||
renderExplorer();
|
||||
|
||||
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledWith(
|
||||
initialQueriesMap[DataSource.METRICS],
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
DataSource.METRICS,
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable one chart per query toggle when oneChartPerQuery=true in URL', () => {
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({ isOneChartPerQueryEnabled: 'true' }),
|
||||
@@ -221,46 +241,20 @@ describe('Explorer', () => {
|
||||
expect(yAxisUnitSelector).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('one chart per query toggle should be forced on and disabled when multiple metrics have different units', () => {
|
||||
const mockQueryData = {
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
aggregateAttribute: {
|
||||
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
|
||||
.aggregateAttribute as BaseAutocompleteData),
|
||||
key: 'metric1',
|
||||
},
|
||||
};
|
||||
const mockStagedQueryWithMultipleQueries = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [mockQueryData, mockQueryData],
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
|
||||
...mockUseQueryBuilderData,
|
||||
stagedQuery: mockStagedQueryWithMultipleQueries,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
it('should hide y axis unit selector for multiple metrics with different units', () => {
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [
|
||||
{ ...MOCK_METRIC_METADATA, unit: 'seconds' },
|
||||
{ ...MOCK_METRIC_METADATA, unit: 'bytes' },
|
||||
],
|
||||
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
|
||||
});
|
||||
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
|
||||
renderExplorer();
|
||||
|
||||
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
|
||||
expect(yAxisUnitSelector).not.toBeInTheDocument();
|
||||
|
||||
// One chart per query toggle should be disabled
|
||||
const oneChartPerQueryToggle = screen.getByRole('switch');
|
||||
expect(oneChartPerQueryToggle).toBeChecked();
|
||||
expect(oneChartPerQueryToggle).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -333,53 +327,4 @@ describe('Explorer', () => {
|
||||
const oneChartPerQueryToggle = screen.getByRole('switch');
|
||||
expect(oneChartPerQueryToggle).toBeEnabled();
|
||||
});
|
||||
|
||||
it('one chart per query toggle should be enabled when multiple metrics have no unit', () => {
|
||||
const metricWithNoUnit = {
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
description: 'metric without unit',
|
||||
unit: '',
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
isMonotonic: true,
|
||||
};
|
||||
const mockQueryData = {
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
aggregateAttribute: {
|
||||
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
|
||||
.aggregateAttribute as BaseAutocompleteData),
|
||||
key: 'metric1',
|
||||
},
|
||||
};
|
||||
const mockStagedQueryWithMultipleQueries = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [mockQueryData, mockQueryData],
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
|
||||
...mockUseQueryBuilderData,
|
||||
stagedQuery: mockStagedQueryWithMultipleQueries,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [metricWithNoUnit, metricWithNoUnit],
|
||||
});
|
||||
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
|
||||
renderExplorer();
|
||||
|
||||
const oneChartPerQueryToggle = screen.getByRole('switch');
|
||||
// Toggle should be enabled (not forced/disabled) since both metrics
|
||||
// have the same unit (no unit) and should be viewable on the same graph
|
||||
expect(oneChartPerQueryToggle).toBeEnabled();
|
||||
expect(oneChartPerQueryToggle).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
|
||||
@@ -56,7 +56,7 @@ const mockSetYAxisUnit = jest.fn();
|
||||
|
||||
function renderTimeSeries(
|
||||
overrides: Partial<TimeSeriesProps> = {},
|
||||
): ReturnType<typeof render> {
|
||||
): RenderResult {
|
||||
return render(
|
||||
<TimeSeries
|
||||
showOneChartPerQuery={false}
|
||||
@@ -84,57 +84,45 @@ describe('TimeSeries', () => {
|
||||
} as Partial<UseUpdateMetricMetadataReturnType>) as UseUpdateMetricMetadataReturnType);
|
||||
});
|
||||
|
||||
it('shows select metric message when no metric is selected', () => {
|
||||
renderTimeSeries({ metricNames: [] });
|
||||
|
||||
expect(
|
||||
screen.getByText('Select a metric and run a query to see the results'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('TimeSeriesView')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders chart view when a metric is selected', () => {
|
||||
renderTimeSeries({
|
||||
metricNames: ['metric1'],
|
||||
metricUnits: ['count'],
|
||||
metrics: [MOCK_METRIC_METADATA],
|
||||
});
|
||||
|
||||
expect(screen.getByText('TimeSeriesView')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Select a metric and run a query to see the results'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a warning icon when a metric has no unit among multiple metrics', () => {
|
||||
renderTimeSeries({
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderTimeSeries({
|
||||
metricUnits: ['', 'count'],
|
||||
metricNames: ['metric1', 'metric2'],
|
||||
metrics: [undefined, undefined],
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('img', { name: 'no unit warning' }),
|
||||
).toBeInTheDocument();
|
||||
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
|
||||
user.hover(alertIcon);
|
||||
waitFor(() =>
|
||||
expect(
|
||||
screen.findByText('This metric does not have a unit'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('warning tooltip shows metric details link', async () => {
|
||||
it('clicking on warning icon tooltip should open metric details modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTimeSeries({
|
||||
const { container } = renderTimeSeries({
|
||||
metricUnits: ['', 'count'],
|
||||
metricNames: ['metric1', 'metric2'],
|
||||
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
|
||||
yAxisUnit: 'seconds',
|
||||
});
|
||||
|
||||
const alertIcon = screen.getByRole('img', { name: 'no unit warning' });
|
||||
await user.hover(alertIcon);
|
||||
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
|
||||
user.hover(alertIcon);
|
||||
|
||||
expect(await screen.findByText('metric details')).toBeInTheDocument();
|
||||
const metricDetailsLink = await screen.findByText('metric details');
|
||||
user.click(metricDetailsLink);
|
||||
|
||||
waitFor(() =>
|
||||
expect(mockSetIsMetricDetailsOpen).toHaveBeenCalledWith('metric1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows save unit prompt with enabled button when metric has no unit and a unit is selected', async () => {
|
||||
renderTimeSeries({
|
||||
it('shows Save unit button when metric had no unit but one is selected', async () => {
|
||||
const { findByText, getByRole } = renderTimeSeries({
|
||||
metricUnits: [undefined],
|
||||
metricNames: ['metric1'],
|
||||
metrics: [MOCK_METRIC_METADATA],
|
||||
@@ -143,10 +131,38 @@ describe('TimeSeries', () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText('Set the selected unit as the metric unit?'),
|
||||
await findByText('Save the selected unit for this metric?'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const yesButton = screen.getByRole('button', { name: 'Yes' });
|
||||
const yesButton = getByRole('button', { name: 'Yes' });
|
||||
expect(yesButton).toBeInTheDocument();
|
||||
expect(yesButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('clicking on save unit button shoould upated metric metadata', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderTimeSeries({
|
||||
metricUnits: [''],
|
||||
metricNames: ['metric1'],
|
||||
metrics: [MOCK_METRIC_METADATA],
|
||||
yAxisUnit: 'seconds',
|
||||
showYAxisUnitSelector: true,
|
||||
});
|
||||
|
||||
const yesButton = getByRole('button', { name: /Yes/i });
|
||||
await user.click(yesButton);
|
||||
|
||||
expect(mockUpdateMetricMetadata).toHaveBeenCalledWith(
|
||||
{
|
||||
pathParams: {
|
||||
metricName: 'metric1',
|
||||
},
|
||||
data: expect.objectContaining({ unit: 'seconds' }),
|
||||
},
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,14 +139,4 @@ describe('getMetricUnits', () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('s');
|
||||
});
|
||||
|
||||
it('should return undefined for metrics with no unit', () => {
|
||||
const result = getMetricUnits([
|
||||
{ ...MOCK_METRIC_METADATA, unit: '' },
|
||||
{ ...MOCK_METRIC_METADATA, unit: '' },
|
||||
]);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBeUndefined();
|
||||
expect(result[1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { MetricNameSelector } from 'container/QueryBuilder/filters';
|
||||
import { AggregatorFilter } from 'container/QueryBuilder/filters';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -27,7 +27,7 @@ function MetricNameSearch({
|
||||
className="inspect-metrics-input-group metric-name-search"
|
||||
>
|
||||
<Typography.Text>From</Typography.Text>
|
||||
<MetricNameSelector
|
||||
<AggregatorFilter
|
||||
defaultValue={searchText ?? ''}
|
||||
query={initialQueriesMap[DataSource.METRICS].builder.queryData[0]}
|
||||
onSelect={handleSetMetricName}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as metricsService from 'api/generated/services/metrics';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
import store from 'store';
|
||||
@@ -24,31 +23,27 @@ jest.mock('react-router-dom', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/metrics', () => ({
|
||||
useListMetrics: jest.fn().mockReturnValue({
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
data: { data: { metrics: [] } },
|
||||
}),
|
||||
useUpdateMetricMetadata: jest.fn().mockReturnValue({
|
||||
mutate: jest.fn(),
|
||||
isLoading: false,
|
||||
}),
|
||||
jest.mock('container/QueryBuilder/filters', () => ({
|
||||
AggregatorFilter: ({ onSelect, onChange, defaultValue }: any): JSX.Element => (
|
||||
<div data-testid="mock-aggregator-filter">
|
||||
<input
|
||||
data-testid="metric-name-input"
|
||||
defaultValue={defaultValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
|
||||
onChange({ key: e.target.value })
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-metric-button"
|
||||
onClick={(): void => onSelect({ key: 'test_metric_2' })}
|
||||
>
|
||||
Select Metric
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDebounce', () => ({
|
||||
__esModule: true,
|
||||
default: <T,>(value: T): T => value,
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
|
||||
}),
|
||||
);
|
||||
|
||||
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
|
||||
user: {
|
||||
role: 'admin',
|
||||
@@ -128,24 +123,6 @@ describe('QueryBuilder', () => {
|
||||
|
||||
it('should call setCurrentMetricName when metric name is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
(metricsService.useListMetrics as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
data: {
|
||||
data: {
|
||||
metrics: [
|
||||
{
|
||||
metricName: 'test_metric_2',
|
||||
type: 'Sum',
|
||||
isMonotonic: true,
|
||||
description: '',
|
||||
temporality: 'cumulative',
|
||||
unit: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -160,12 +137,8 @@ describe('QueryBuilder', () => {
|
||||
|
||||
expect(screen.getByText('From')).toBeInTheDocument();
|
||||
|
||||
const input = within(metricNameSearch).getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'test_metric_2' } });
|
||||
|
||||
const options = document.querySelectorAll('.ant-select-item');
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
await user.click(options[0] as HTMLElement);
|
||||
const selectButton = screen.getByTestId('select-metric-button');
|
||||
await user.click(selectButton);
|
||||
|
||||
expect(mockSetCurrentMetricName).toHaveBeenCalledWith('test_metric_2');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Button,
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Input,
|
||||
Menu,
|
||||
Popover,
|
||||
Tooltip,
|
||||
Skeleton,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
@@ -14,50 +14,148 @@ import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricAttributes } from 'api/generated/services/metrics';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { Check, Copy, Info, Search, SquareArrowOutUpRight } from 'lucide-react';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Compass, Copy, Search } from 'lucide-react';
|
||||
|
||||
import { PANEL_TYPES } from '../../../constants/queryBuilder';
|
||||
import ROUTES from '../../../constants/routes';
|
||||
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import MetricDetailsErrorState from './MetricDetailsErrorState';
|
||||
import {
|
||||
AllAttributesEmptyText,
|
||||
AllAttributesValue,
|
||||
} from './AllAttributesValue';
|
||||
import { AllAttributesProps } from './types';
|
||||
AllAttributesEmptyTextProps,
|
||||
AllAttributesProps,
|
||||
AllAttributesValueProps,
|
||||
} from './types';
|
||||
import { getMetricDetailsQuery } from './utils';
|
||||
|
||||
const ALL_ATTRIBUTES_KEY = 'all-attributes';
|
||||
const COPY_FEEDBACK_DURATION_MS = 1500;
|
||||
|
||||
function AllAttributesEmptyText({
|
||||
isErrorAttributes,
|
||||
refetchAttributes,
|
||||
}: AllAttributesEmptyTextProps): JSX.Element {
|
||||
if (isErrorAttributes) {
|
||||
return (
|
||||
<div className="all-attributes-error-state">
|
||||
<MetricDetailsErrorState
|
||||
refetch={refetchAttributes}
|
||||
errorMessage="Something went wrong while fetching attributes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Typography.Text>No attributes found</Typography.Text>;
|
||||
}
|
||||
|
||||
export function AllAttributesValue({
|
||||
filterKey,
|
||||
filterValue,
|
||||
goToMetricsExploreWithAppliedAttribute,
|
||||
}: AllAttributesValueProps): JSX.Element {
|
||||
const [visibleIndex, setVisibleIndex] = useState(5);
|
||||
const [attributePopoverKey, setAttributePopoverKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const handleShowMore = (): void => {
|
||||
setVisibleIndex(visibleIndex + 5);
|
||||
};
|
||||
|
||||
const handleMenuItemClick = useCallback(
|
||||
(key: string, attribute: string): void => {
|
||||
switch (key) {
|
||||
case 'open-in-explorer':
|
||||
goToMetricsExploreWithAppliedAttribute(filterKey, attribute);
|
||||
break;
|
||||
case 'copy-attribute':
|
||||
copyToClipboard(attribute);
|
||||
notifications.success({
|
||||
message: 'Attribute copied!',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
setAttributePopoverKey(null);
|
||||
},
|
||||
[
|
||||
goToMetricsExploreWithAppliedAttribute,
|
||||
filterKey,
|
||||
copyToClipboard,
|
||||
notifications,
|
||||
],
|
||||
);
|
||||
|
||||
const attributePopoverContent = useCallback(
|
||||
(attribute: string) => (
|
||||
<Menu
|
||||
items={[
|
||||
{
|
||||
icon: <Compass size={16} />,
|
||||
label: 'Open in Explorer',
|
||||
key: 'open-in-explorer',
|
||||
},
|
||||
{
|
||||
icon: <Copy size={16} />,
|
||||
label: 'Copy Attribute',
|
||||
key: 'copy-attribute',
|
||||
},
|
||||
]}
|
||||
onClick={(info): void => {
|
||||
handleMenuItemClick(info.key, attribute);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[handleMenuItemClick],
|
||||
);
|
||||
return (
|
||||
<div className="all-attributes-value">
|
||||
{filterValue.slice(0, visibleIndex).map((attribute) => (
|
||||
<Popover
|
||||
key={attribute}
|
||||
content={attributePopoverContent(attribute)}
|
||||
trigger="click"
|
||||
open={attributePopoverKey === `${filterKey}-${attribute}`}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
setAttributePopoverKey(null);
|
||||
} else {
|
||||
setAttributePopoverKey(`${filterKey}-${attribute}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button key={attribute} type="text">
|
||||
<Typography.Text>{attribute}</Typography.Text>
|
||||
</Button>
|
||||
</Popover>
|
||||
))}
|
||||
{visibleIndex < filterValue.length && (
|
||||
<Button type="text" onClick={handleShowMore}>
|
||||
Show More
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AllAttributes({
|
||||
metricName,
|
||||
metricType,
|
||||
isMonotonic,
|
||||
minTime,
|
||||
maxTime,
|
||||
}: AllAttributesProps): JSX.Element {
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const [activeKey, setActiveKey] = useState<string[]>([ALL_ATTRIBUTES_KEY]);
|
||||
const [keyPopoverOpen, setKeyPopoverOpen] = useState<string | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const {
|
||||
data: attributesData,
|
||||
isLoading: isLoadingAttributes,
|
||||
isError: isErrorAttributes,
|
||||
refetch: refetchAttributes,
|
||||
} = useGetMetricAttributes(
|
||||
{
|
||||
metricName,
|
||||
},
|
||||
{
|
||||
start: minTime ? Math.floor(minTime / 1000000) : undefined,
|
||||
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
|
||||
},
|
||||
);
|
||||
} = useGetMetricAttributes({
|
||||
metricName,
|
||||
});
|
||||
|
||||
const attributes = useMemo(() => attributesData?.data.attributes ?? [], [
|
||||
attributesData,
|
||||
@@ -66,15 +164,12 @@ function AllAttributes({
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const goToMetricsExplorerwithAppliedSpaceAggregation = useCallback(
|
||||
(groupBy: string, valueCount?: number) => {
|
||||
const limit = valueCount && valueCount > 250 ? 100 : undefined;
|
||||
(groupBy: string) => {
|
||||
const compositeQuery = getMetricDetailsQuery(
|
||||
metricName,
|
||||
metricType,
|
||||
undefined,
|
||||
groupBy,
|
||||
limit,
|
||||
isMonotonic,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -84,7 +179,6 @@ function AllAttributes({
|
||||
id: metricName,
|
||||
},
|
||||
ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
true,
|
||||
);
|
||||
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
@@ -93,19 +187,15 @@ function AllAttributes({
|
||||
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, isMonotonic, handleExplorerTabChange],
|
||||
[metricName, metricType, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const goToMetricsExploreWithAppliedAttribute = useCallback(
|
||||
(key: string, value: string) => {
|
||||
const compositeQuery = getMetricDetailsQuery(
|
||||
metricName,
|
||||
metricType,
|
||||
{ key, value },
|
||||
undefined,
|
||||
undefined,
|
||||
isMonotonic,
|
||||
);
|
||||
const compositeQuery = getMetricDetailsQuery(metricName, metricType, {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{
|
||||
@@ -114,7 +204,6 @@ function AllAttributes({
|
||||
id: metricName,
|
||||
},
|
||||
ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
true,
|
||||
);
|
||||
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
@@ -124,29 +213,7 @@ function AllAttributes({
|
||||
[MetricsExplorerEventKeys.AttributeValue]: value,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, isMonotonic, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const handleKeyMenuItemClick = useCallback(
|
||||
(menuKey: string, attributeKey: string, valueCount?: number): void => {
|
||||
switch (menuKey) {
|
||||
case 'open-in-explorer':
|
||||
goToMetricsExplorerwithAppliedSpaceAggregation(attributeKey, valueCount);
|
||||
break;
|
||||
case 'copy-key':
|
||||
copyToClipboard(attributeKey);
|
||||
setCopiedKey(attributeKey);
|
||||
clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(() => {
|
||||
setCopiedKey(null);
|
||||
}, COPY_FEEDBACK_DURATION_MS);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
setKeyPopoverOpen(null);
|
||||
},
|
||||
[goToMetricsExplorerwithAppliedSpaceAggregation, copyToClipboard],
|
||||
[metricName, metricType, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const filteredAttributes = useMemo(
|
||||
@@ -187,57 +254,21 @@ function AllAttributes({
|
||||
width: 50,
|
||||
align: 'left',
|
||||
className: 'metric-metadata-key',
|
||||
render: (field: { label: string; contribution: number }): JSX.Element => {
|
||||
const isCopied = copiedKey === field.label;
|
||||
return (
|
||||
<div className="all-attributes-key">
|
||||
<Popover
|
||||
content={
|
||||
<Menu
|
||||
items={[
|
||||
{
|
||||
icon: <SquareArrowOutUpRight size={14} />,
|
||||
label: 'Open in Metric Explorer',
|
||||
key: 'open-in-explorer',
|
||||
},
|
||||
{
|
||||
icon: <Copy size={14} />,
|
||||
label: 'Copy Key',
|
||||
key: 'copy-key',
|
||||
},
|
||||
]}
|
||||
onClick={(info): void => {
|
||||
handleKeyMenuItemClick(info.key, field.label, field.contribution);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
placement="right"
|
||||
overlayClassName="metric-details-popover attribute-key-popover-overlay"
|
||||
open={keyPopoverOpen === field.label}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
setKeyPopoverOpen(null);
|
||||
} else {
|
||||
setKeyPopoverOpen(field.label);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="text">
|
||||
<Typography.Text>{field.label}</Typography.Text>
|
||||
</Button>
|
||||
</Popover>
|
||||
{isCopied && (
|
||||
<span className="copy-feedback">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
<Typography.Text className="all-attributes-contribution">
|
||||
{field.contribution}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
render: (field: { label: string; contribution: number }): JSX.Element => (
|
||||
<div className="all-attributes-key">
|
||||
<Button
|
||||
type="text"
|
||||
onClick={(): void =>
|
||||
goToMetricsExplorerwithAppliedSpaceAggregation(field.label)
|
||||
}
|
||||
>
|
||||
<Typography.Text>{field.label}</Typography.Text>
|
||||
</Button>
|
||||
<Typography.Text className="all-attributes-contribution">
|
||||
{field.contribution}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Value',
|
||||
@@ -260,9 +291,7 @@ function AllAttributes({
|
||||
],
|
||||
[
|
||||
goToMetricsExploreWithAppliedAttribute,
|
||||
handleKeyMenuItemClick,
|
||||
keyPopoverOpen,
|
||||
copiedKey,
|
||||
goToMetricsExplorerwithAppliedSpaceAggregation,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -271,12 +300,7 @@ function AllAttributes({
|
||||
{
|
||||
label: (
|
||||
<div className="metrics-accordion-header">
|
||||
<div className="all-attributes-header-title">
|
||||
<Typography.Text>All Attributes</Typography.Text>
|
||||
<Tooltip title="Showing attributes for the selected time range">
|
||||
<Info size={14} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Typography.Text>All Attributes</Typography.Text>
|
||||
<Input
|
||||
className="all-attributes-search-input"
|
||||
placeholder="Search"
|
||||
@@ -305,9 +329,7 @@ function AllAttributes({
|
||||
className="metrics-accordion-content all-attributes-content"
|
||||
scroll={{ y: 600 }}
|
||||
locale={{
|
||||
emptyText: isLoadingAttributes ? (
|
||||
' '
|
||||
) : (
|
||||
emptyText: (
|
||||
<AllAttributesEmptyText
|
||||
isErrorAttributes={isErrorAttributes}
|
||||
refetchAttributes={refetchAttributes}
|
||||
@@ -328,6 +350,14 @@ function AllAttributes({
|
||||
],
|
||||
);
|
||||
|
||||
if (isLoadingAttributes) {
|
||||
return (
|
||||
<div className="all-attributes-skeleton-container">
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
bordered
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button, Input, Menu, Popover, Tooltip, Typography } from 'antd';
|
||||
import { Check, Copy, Search, SquareArrowOutUpRight } from 'lucide-react';
|
||||
|
||||
import MetricDetailsErrorState from './MetricDetailsErrorState';
|
||||
import { AllAttributesEmptyTextProps, AllAttributesValueProps } from './types';
|
||||
|
||||
const INITIAL_VISIBLE_COUNT = 5;
|
||||
const COPY_FEEDBACK_DURATION_MS = 1500;
|
||||
|
||||
export function AllAttributesEmptyText({
|
||||
isErrorAttributes,
|
||||
refetchAttributes,
|
||||
}: AllAttributesEmptyTextProps): JSX.Element {
|
||||
if (isErrorAttributes) {
|
||||
return (
|
||||
<div className="all-attributes-error-state">
|
||||
<MetricDetailsErrorState
|
||||
refetch={refetchAttributes}
|
||||
errorMessage="Something went wrong while fetching attributes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Typography.Text>No attributes found</Typography.Text>;
|
||||
}
|
||||
|
||||
export function AllAttributesValue({
|
||||
filterKey,
|
||||
filterValue,
|
||||
goToMetricsExploreWithAppliedAttribute,
|
||||
}: AllAttributesValueProps): JSX.Element {
|
||||
const [attributePopoverKey, setAttributePopoverKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [allValuesOpen, setAllValuesOpen] = useState(false);
|
||||
const [allValuesSearch, setAllValuesSearch] = useState('');
|
||||
const [copiedValue, setCopiedValue] = useState<string | null>(null);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopyWithFeedback = useCallback(
|
||||
(value: string): void => {
|
||||
copyToClipboard(value);
|
||||
setCopiedValue(value);
|
||||
clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(() => {
|
||||
setCopiedValue(null);
|
||||
}, COPY_FEEDBACK_DURATION_MS);
|
||||
},
|
||||
[copyToClipboard],
|
||||
);
|
||||
|
||||
const handleMenuItemClick = useCallback(
|
||||
(key: string, attribute: string): void => {
|
||||
switch (key) {
|
||||
case 'open-in-explorer':
|
||||
goToMetricsExploreWithAppliedAttribute(filterKey, attribute);
|
||||
break;
|
||||
case 'copy-value':
|
||||
handleCopyWithFeedback(attribute);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
setAttributePopoverKey(null);
|
||||
},
|
||||
[goToMetricsExploreWithAppliedAttribute, filterKey, handleCopyWithFeedback],
|
||||
);
|
||||
|
||||
const attributePopoverContent = useCallback(
|
||||
(attribute: string) => (
|
||||
<Menu
|
||||
items={[
|
||||
{
|
||||
icon: <SquareArrowOutUpRight size={14} />,
|
||||
label: 'Open in Metric Explorer',
|
||||
key: 'open-in-explorer',
|
||||
},
|
||||
{
|
||||
icon: <Copy size={14} />,
|
||||
label: 'Copy Value',
|
||||
key: 'copy-value',
|
||||
},
|
||||
]}
|
||||
onClick={(info): void => {
|
||||
handleMenuItemClick(info.key, attribute);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[handleMenuItemClick],
|
||||
);
|
||||
|
||||
const filteredAllValues = useMemo(
|
||||
() =>
|
||||
allValuesSearch
|
||||
? filterValue.filter((v) =>
|
||||
v.toLowerCase().includes(allValuesSearch.toLowerCase()),
|
||||
)
|
||||
: filterValue,
|
||||
[filterValue, allValuesSearch],
|
||||
);
|
||||
|
||||
const allValuesPopoverContent = (
|
||||
<div className="all-values-popover">
|
||||
<Input
|
||||
placeholder="Search values"
|
||||
size="small"
|
||||
prefix={<Search size={12} />}
|
||||
value={allValuesSearch}
|
||||
onChange={(e): void => setAllValuesSearch(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<div className="all-values-list">
|
||||
{allValuesOpen &&
|
||||
filteredAllValues.map((attribute) => {
|
||||
const isCopied = copiedValue === attribute;
|
||||
return (
|
||||
<div key={attribute} className="all-values-item">
|
||||
<Typography.Text ellipsis className="all-values-item-text">
|
||||
{attribute}
|
||||
</Typography.Text>
|
||||
<div className="all-values-item-actions">
|
||||
<Tooltip title={isCopied ? 'Copied!' : 'Copy value'}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={isCopied ? 'copy-success' : ''}
|
||||
icon={isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
onClick={(): void => {
|
||||
handleCopyWithFeedback(attribute);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Open in Metric Explorer">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<SquareArrowOutUpRight size={12} />}
|
||||
onClick={(): void => {
|
||||
goToMetricsExploreWithAppliedAttribute(filterKey, attribute);
|
||||
setAllValuesOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{allValuesOpen && filteredAllValues.length === 0 && (
|
||||
<Typography.Text type="secondary" className="all-values-empty">
|
||||
No values found
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="all-attributes-value">
|
||||
{filterValue.slice(0, INITIAL_VISIBLE_COUNT).map((attribute) => {
|
||||
const isCopied = copiedValue === attribute;
|
||||
return (
|
||||
<div key={attribute} className="all-attributes-value-item">
|
||||
<Popover
|
||||
content={attributePopoverContent(attribute)}
|
||||
trigger="click"
|
||||
overlayClassName="metric-details-popover attribute-value-popover-overlay"
|
||||
open={attributePopoverKey === `${filterKey}-${attribute}`}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
setAttributePopoverKey(null);
|
||||
} else {
|
||||
setAttributePopoverKey(`${filterKey}-${attribute}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="text">
|
||||
<Typography.Text>{attribute}</Typography.Text>
|
||||
</Button>
|
||||
</Popover>
|
||||
{isCopied && (
|
||||
<span className="copy-feedback">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filterValue.length > INITIAL_VISIBLE_COUNT && (
|
||||
<Popover
|
||||
content={allValuesPopoverContent}
|
||||
trigger="click"
|
||||
open={allValuesOpen}
|
||||
onOpenChange={(open): void => {
|
||||
setAllValuesOpen(open);
|
||||
if (!open) {
|
||||
setAllValuesSearch('');
|
||||
setCopiedValue(null);
|
||||
}
|
||||
}}
|
||||
overlayClassName="metric-details-popover all-values-popover-overlay"
|
||||
>
|
||||
<Button type="text" className="all-values-button">
|
||||
All values ({filterValue.length})
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
} from 'api/generated/services/metrics';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import history from 'lib/history';
|
||||
import { Bell, Grid } from 'lucide-react';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
@@ -18,6 +20,7 @@ import { DashboardsAndAlertsPopoverProps } from './types';
|
||||
function DashboardsAndAlertsPopover({
|
||||
metricName,
|
||||
}: DashboardsAndAlertsPopoverProps): JSX.Element | null {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const params = useUrlQuery();
|
||||
|
||||
const {
|
||||
@@ -72,7 +75,7 @@ function DashboardsAndAlertsPopover({
|
||||
key={alert.alertId}
|
||||
onClick={(): void => {
|
||||
params.set(QueryParams.ruleId, alert.alertId);
|
||||
window.open(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`, '_blank');
|
||||
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
}}
|
||||
className="dashboards-popover-content-item"
|
||||
>
|
||||
@@ -92,11 +95,10 @@ function DashboardsAndAlertsPopover({
|
||||
<Typography.Link
|
||||
key={dashboard.dashboardId}
|
||||
onClick={(): void => {
|
||||
window.open(
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, {
|
||||
dashboardId: dashboard.dashboardId,
|
||||
}),
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
className="dashboards-popover-content-item"
|
||||
@@ -107,7 +109,7 @@ function DashboardsAndAlertsPopover({
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
}, [dashboards]);
|
||||
}, [dashboards, safeNavigate]);
|
||||
|
||||
if (isLoadingAlerts || isLoadingDashboards) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Spin, Tooltip, Typography } from 'antd';
|
||||
import { Button, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { useGetMetricHighlights } from 'api/generated/services/metrics';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
|
||||
@@ -39,6 +39,17 @@ function Highlights({ metricName }: HighlightsProps): JSX.Element {
|
||||
metricHighlights?.lastReceived,
|
||||
);
|
||||
|
||||
if (isLoadingMetricHighlights) {
|
||||
return (
|
||||
<div
|
||||
className="metric-details-content-grid"
|
||||
data-testid="metric-highlights-loading-state"
|
||||
>
|
||||
<Skeleton title={false} paragraph={{ rows: 2 }} active />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorMetricHighlights) {
|
||||
return (
|
||||
<div className="metric-details-content-grid">
|
||||
@@ -78,41 +89,32 @@ function Highlights({ metricName }: HighlightsProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="values-row">
|
||||
{isLoadingMetricHighlights ? (
|
||||
<div className="metric-highlights-loading-inline">
|
||||
<Spin size="small" />
|
||||
<Typography.Text type="secondary">Loading metric stats</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-data-points"
|
||||
>
|
||||
<Tooltip title={metricHighlights?.dataPoints?.toLocaleString()}>
|
||||
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-time-series-total"
|
||||
>
|
||||
<Tooltip
|
||||
title="Active time series are those that have received data points in the last 1
|
||||
hour."
|
||||
placement="top"
|
||||
>
|
||||
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-last-received"
|
||||
>
|
||||
<Tooltip title={lastReceivedText}>{lastReceivedText}</Tooltip>
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-data-points"
|
||||
>
|
||||
<Tooltip title={metricHighlights?.dataPoints?.toLocaleString()}>
|
||||
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-time-series-total"
|
||||
>
|
||||
<Tooltip
|
||||
title="Active time series are those that have received data points in the last 1
|
||||
hour."
|
||||
placement="top"
|
||||
>
|
||||
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-last-received"
|
||||
>
|
||||
<Tooltip title={lastReceivedText}>{lastReceivedText}</Tooltip>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button, Collapse, Input, Select, Spin, Typography } from 'antd';
|
||||
import { Button, Collapse, Input, Select, Skeleton, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
@@ -334,7 +334,7 @@ function Metadata({
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
disabled={isUpdatingMetricsMetadata || isLoadingMetricMetadata}
|
||||
disabled={isUpdatingMetricsMetadata}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
<Typography.Text>Edit</Typography.Text>
|
||||
@@ -345,7 +345,6 @@ function Metadata({
|
||||
isEditing,
|
||||
isErrorMetricMetadata,
|
||||
isUpdatingMetricsMetadata,
|
||||
isLoadingMetricMetadata,
|
||||
cancelEdit,
|
||||
handleSave,
|
||||
]);
|
||||
@@ -360,11 +359,7 @@ function Metadata({
|
||||
</div>
|
||||
),
|
||||
key: 'metric-metadata',
|
||||
children: isLoadingMetricMetadata ? (
|
||||
<div className="metrics-accordion-loading-state">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : isErrorMetricMetadata ? (
|
||||
children: isErrorMetricMetadata ? (
|
||||
<div className="metric-metadata-error-state">
|
||||
<MetricDetailsErrorState
|
||||
refetch={refetchMetricMetadata}
|
||||
@@ -386,13 +381,20 @@ function Metadata({
|
||||
[
|
||||
actionButton,
|
||||
columns,
|
||||
isLoadingMetricMetadata,
|
||||
isErrorMetricMetadata,
|
||||
refetchMetricMetadata,
|
||||
tableData,
|
||||
],
|
||||
);
|
||||
|
||||
if (isLoadingMetricMetadata) {
|
||||
return (
|
||||
<div className="metrics-metadata-skeleton-container">
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
bordered
|
||||
|
||||
@@ -52,13 +52,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric-highlights-loading-inline {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric-highlights-error-state {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -127,11 +120,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-accordion-loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
.metrics-metadata-skeleton-container {
|
||||
height: 330px;
|
||||
}
|
||||
|
||||
.all-attributes-skeleton-container {
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.metrics-accordion {
|
||||
@@ -159,18 +153,6 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
|
||||
.all-attributes-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.lucide-info {
|
||||
cursor: pointer;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
font-family: 'Geist Mono';
|
||||
color: var(--bg-robin-400);
|
||||
@@ -204,7 +186,6 @@
|
||||
.all-attributes-key {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.ant-btn {
|
||||
.ant-typography:first-child {
|
||||
font-family: 'Geist Mono';
|
||||
@@ -212,15 +193,17 @@
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
.copy-feedback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--bg-forest-500);
|
||||
animation: fade-in-out 1.5s ease-in-out;
|
||||
}
|
||||
.all-attributes-contribution {
|
||||
font-family: 'Geist Mono';
|
||||
color: var(--bg-vanilla-400);
|
||||
background-color: rgba(171, 189, 255, 0.1);
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,18 +228,6 @@
|
||||
background: var(--bg-slate-300);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.all-attributes-value-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.copy-feedback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--bg-forest-500);
|
||||
animation: fade-in-out 1.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
.ant-btn {
|
||||
text-align: left;
|
||||
width: fit-content;
|
||||
@@ -288,8 +259,10 @@
|
||||
}
|
||||
|
||||
.metric-metadata-key {
|
||||
cursor: pointer;
|
||||
padding-left: 10px;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
.field-renderer-container {
|
||||
.label {
|
||||
color: var(--bg-vanilla-400);
|
||||
@@ -410,8 +383,9 @@
|
||||
}
|
||||
|
||||
.all-attributes-key {
|
||||
.all-attributes-contribution {
|
||||
color: var(--bg-slate-400);
|
||||
.ant-typography:last-child {
|
||||
color: var(--bg-vanilla-200);
|
||||
background-color: var(--bg-robin-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,146 +448,3 @@
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attribute-key-popover-overlay {
|
||||
.ant-popover-inner {
|
||||
padding: 0 !important;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 0.8) 0%,
|
||||
rgba(18, 19, 23, 0.9) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.ant-menu {
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
|
||||
.ant-menu-item {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.all-values-popover-overlay {
|
||||
.ant-popover-inner {
|
||||
padding: 0 !important;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 0.8) 0%,
|
||||
rgba(18, 19, 23, 0.9) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.all-values-popover {
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
|
||||
.all-values-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-300);
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.all-values-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.all-values-item-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.all-values-item-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.copy-success {
|
||||
color: var(--bg-forest-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.all-values-empty {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.all-values-button {
|
||||
color: var(--bg-robin-400) !important;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.attribute-key-popover-overlay,
|
||||
.all-values-popover-overlay {
|
||||
.ant-popover-inner {
|
||||
border: 1px solid var(--bg-vanilla-400);
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.all-values-popover {
|
||||
.all-values-item {
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-out {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
85% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Divider, Drawer, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricMetadata } from 'api/generated/services/metrics';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Compass, Crosshair, X } from 'lucide-react';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { PANEL_TYPES } from '../../../constants/queryBuilder';
|
||||
import ROUTES from '../../../constants/routes';
|
||||
@@ -33,9 +29,6 @@ function MetricDetails({
|
||||
}: MetricDetailsProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const {
|
||||
data: metricMetadataResponse,
|
||||
@@ -80,14 +73,7 @@ function MetricDetails({
|
||||
|
||||
const goToMetricsExplorerwithSelectedMetric = useCallback(() => {
|
||||
if (metricName) {
|
||||
const compositeQuery = getMetricDetailsQuery(
|
||||
metricName,
|
||||
metadata?.type,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
metadata?.isMonotonic,
|
||||
);
|
||||
const compositeQuery = getMetricDetailsQuery(metricName, metadata?.type);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{
|
||||
@@ -96,7 +82,6 @@ function MetricDetails({
|
||||
id: metricName,
|
||||
},
|
||||
ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
true,
|
||||
);
|
||||
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
@@ -104,12 +89,7 @@ function MetricDetails({
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
});
|
||||
}
|
||||
}, [
|
||||
metricName,
|
||||
handleExplorerTabChange,
|
||||
metadata?.type,
|
||||
metadata?.isMonotonic,
|
||||
]);
|
||||
}, [metricName, handleExplorerTabChange, metadata?.type]);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.ModalOpened, {
|
||||
@@ -120,21 +100,6 @@ function MetricDetails({
|
||||
const isActionButtonDisabled =
|
||||
!metricName || isLoadingMetricMetadata || isErrorMetricMetadata;
|
||||
|
||||
const handleDrawerClose = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent): void => {
|
||||
if ('key' in e && e.key === 'Escape') {
|
||||
const openPopover = document.querySelector(
|
||||
'.metric-details-popover:not(.ant-popover-hidden)',
|
||||
);
|
||||
if (openPopover) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="60%"
|
||||
@@ -172,7 +137,7 @@ function MetricDetails({
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
onClose={handleDrawerClose}
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
style={{
|
||||
overscrollBehavior: 'contain',
|
||||
@@ -192,13 +157,7 @@ function MetricDetails({
|
||||
isLoadingMetricMetadata={isLoadingMetricMetadata}
|
||||
refetchMetricMetadata={refetchMetricMetadata}
|
||||
/>
|
||||
<AllAttributes
|
||||
metricName={metricName}
|
||||
metricType={metadata?.type}
|
||||
isMonotonic={metadata?.isMonotonic}
|
||||
minTime={minTime}
|
||||
maxTime={maxTime}
|
||||
/>
|
||||
<AllAttributes metricName={metricName} metricType={metadata?.type} />
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as reactUseHooks from 'react-use';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
|
||||
import ROUTES from '../../../../constants/routes';
|
||||
import AllAttributes from '../AllAttributes';
|
||||
import { AllAttributesValue } from '../AllAttributesValue';
|
||||
import AllAttributes, { AllAttributesValue } from '../AllAttributes';
|
||||
import { getMockMetricAttributesData, MOCK_METRIC_NAME } from './testUtlls';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
@@ -14,6 +15,17 @@ jest.mock('react-router-dom', () => ({
|
||||
pathname: `${ROUTES.METRICS_EXPLORER}`,
|
||||
}),
|
||||
}));
|
||||
const mockHandleExplorerTabChange = jest.fn();
|
||||
jest
|
||||
.spyOn(useHandleExplorerTabChange, 'useHandleExplorerTabChange')
|
||||
.mockReturnValue({
|
||||
handleExplorerTabChange: mockHandleExplorerTabChange,
|
||||
});
|
||||
|
||||
const mockUseCopyToClipboard = jest.fn();
|
||||
jest
|
||||
.spyOn(reactUseHooks, 'useCopyToClipboard')
|
||||
.mockReturnValue([{ value: 'value1' }, mockUseCopyToClipboard] as any);
|
||||
|
||||
const useGetMetricAttributesMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
@@ -22,13 +34,12 @@ const useGetMetricAttributesMock = jest.spyOn(
|
||||
|
||||
describe('AllAttributes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useGetMetricAttributesMock.mockReturnValue({
|
||||
...getMockMetricAttributesData(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders attribute keys, values, and value counts from API data', () => {
|
||||
it('renders attributes section with title', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
@@ -36,13 +47,39 @@ describe('AllAttributes', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('All Attributes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all attribute keys and values', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check attribute keys are rendered
|
||||
expect(screen.getByText('attribute1')).toBeInTheDocument();
|
||||
expect(screen.getByText('attribute2')).toBeInTheDocument();
|
||||
|
||||
// Check attribute values are rendered
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||
expect(screen.getByText('value3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders value counts correctly', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('2')).toBeInTheDocument(); // For attribute1
|
||||
expect(screen.getByText('1')).toBeInTheDocument(); // For attribute2
|
||||
});
|
||||
|
||||
it('handles empty attributes array', () => {
|
||||
useGetMetricAttributesMock.mockReturnValue({
|
||||
...getMockMetricAttributesData({
|
||||
@@ -63,7 +100,7 @@ describe('AllAttributes', () => {
|
||||
expect(screen.getByText('No attributes found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking on an attribute key shows popover with Open in Metric Explorer option', async () => {
|
||||
it('clicking on an attribute key opens the explorer with the attribute filter applied', async () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
@@ -71,8 +108,7 @@ describe('AllAttributes', () => {
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('attribute1'));
|
||||
expect(screen.getByText('Open in Metric Explorer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Copy Key')).toBeInTheDocument();
|
||||
expect(mockHandleExplorerTabChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters attributes based on search input', async () => {
|
||||
@@ -87,66 +123,26 @@ describe('AllAttributes', () => {
|
||||
expect(screen.getByText('attribute1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state when attribute fetching fails', () => {
|
||||
useGetMetricAttributesMock.mockReturnValue({
|
||||
...getMockMetricAttributesData(
|
||||
{
|
||||
data: {
|
||||
attributes: [],
|
||||
totalKeys: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isError: true,
|
||||
},
|
||||
),
|
||||
});
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Something went wrong while fetching attributes'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show misleading empty text while loading', () => {
|
||||
useGetMetricAttributesMock.mockReturnValue({
|
||||
...getMockMetricAttributesData(
|
||||
{
|
||||
data: {
|
||||
attributes: [],
|
||||
totalKeys: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isLoading: true,
|
||||
},
|
||||
),
|
||||
});
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('No attributes found')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AllAttributesValue', () => {
|
||||
const mockGoToMetricsExploreWithAppliedAttribute = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
it('renders all attribute values', () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
filterValue={['value1', 'value2']}
|
||||
goToMetricsExploreWithAppliedAttribute={
|
||||
mockGoToMetricsExploreWithAppliedAttribute
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows All values button when there are more than 5 values', () => {
|
||||
it('loads more attributes when show more button is clicked', async () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
@@ -157,59 +153,58 @@ describe('AllAttributesValue', () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('value6')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('All values (6)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('All values popover shows values beyond the initial 5', async () => {
|
||||
const values = [
|
||||
'value1',
|
||||
'value2',
|
||||
'value3',
|
||||
'value4',
|
||||
'value5',
|
||||
'value6',
|
||||
'value7',
|
||||
];
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
filterValue={values}
|
||||
goToMetricsExploreWithAppliedAttribute={
|
||||
mockGoToMetricsExploreWithAppliedAttribute
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('All values (7)'));
|
||||
|
||||
await userEvent.click(screen.getByText('Show More'));
|
||||
expect(screen.getByText('value6')).toBeInTheDocument();
|
||||
expect(screen.getByText('value7')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('All values popover search filters the value list', async () => {
|
||||
const values = [
|
||||
'alpha',
|
||||
'bravo',
|
||||
'charlie',
|
||||
'delta',
|
||||
'echo',
|
||||
'fig-special',
|
||||
'golf-target',
|
||||
];
|
||||
it('does not render show more button when there are no more attributes to show', () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
filterValue={values}
|
||||
filterValue={['value1', 'value2']}
|
||||
goToMetricsExploreWithAppliedAttribute={
|
||||
mockGoToMetricsExploreWithAppliedAttribute
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Show More')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText('All values (7)'));
|
||||
await userEvent.type(screen.getByPlaceholderText('Search values'), 'golf');
|
||||
it('copy button should copy the attribute value to the clipboard', async () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
filterValue={['value1', 'value2']}
|
||||
goToMetricsExploreWithAppliedAttribute={
|
||||
mockGoToMetricsExploreWithAppliedAttribute
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('value1'));
|
||||
expect(screen.getByText('Copy Attribute')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('Copy Attribute'));
|
||||
expect(mockUseCopyToClipboard).toHaveBeenCalledWith('value1');
|
||||
});
|
||||
|
||||
expect(screen.getByText('golf-target')).toBeInTheDocument();
|
||||
expect(screen.queryByText('fig-special')).not.toBeInTheDocument();
|
||||
it('explorer button should go to metrics explore with the attribute filter applied', async () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
filterValue={['value1', 'value2']}
|
||||
goToMetricsExploreWithAppliedAttribute={
|
||||
mockGoToMetricsExploreWithAppliedAttribute
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('value1'));
|
||||
|
||||
expect(screen.getByText('Open in Explorer')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('Open in Explorer'));
|
||||
expect(mockGoToMetricsExploreWithAppliedAttribute).toHaveBeenCalledWith(
|
||||
'attribute1',
|
||||
'value1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,12 @@ import {
|
||||
MOCK_METRIC_NAME,
|
||||
} from './testUtlls';
|
||||
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', { value: mockWindowOpen });
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
const mockSetQuery = jest.fn();
|
||||
const mockUrlQuery = {
|
||||
set: mockSetQuery,
|
||||
@@ -39,7 +43,6 @@ describe('DashboardsAndAlertsPopover', () => {
|
||||
beforeEach(() => {
|
||||
useGetMetricAlertsMock.mockReturnValue(getMockAlertsData());
|
||||
useGetMetricDashboardsMock.mockReturnValue(getMockDashboardsData());
|
||||
mockWindowOpen.mockClear();
|
||||
});
|
||||
|
||||
it('renders the popover correctly with multiple dashboards and alerts', () => {
|
||||
@@ -137,10 +140,9 @@ describe('DashboardsAndAlertsPopover', () => {
|
||||
// Click on the first dashboard
|
||||
await userEvent.click(screen.getByText(MOCK_DASHBOARD_1.dashboardName));
|
||||
|
||||
// Should open dashboard in new tab
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
// Should navigate to the dashboard
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/dashboard/${MOCK_DASHBOARD_1.dashboardId}`,
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -156,12 +158,11 @@ describe('DashboardsAndAlertsPopover', () => {
|
||||
// Click on the first alert rule
|
||||
await userEvent.click(screen.getByText(MOCK_ALERT_1.alertName));
|
||||
|
||||
// Should open alert in new tab
|
||||
// Should navigate to the alert rule
|
||||
expect(mockSetQuery).toHaveBeenCalledWith(
|
||||
QueryParams.ruleId,
|
||||
MOCK_ALERT_1.alertId,
|
||||
);
|
||||
expect(mockWindowOpen).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders unique dashboards even when there are duplicates', async () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Highlights', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show labels and loading text but not stale data values while loading', () => {
|
||||
it('should render loading state when data is loading', () => {
|
||||
useGetMetricHighlightsMock.mockReturnValue(
|
||||
getMockMetricHighlightsData(
|
||||
{},
|
||||
@@ -60,19 +60,8 @@ describe('Highlights', () => {
|
||||
|
||||
render(<Highlights metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
expect(screen.getByText('SAMPLES')).toBeInTheDocument();
|
||||
expect(screen.getByText('TIME SERIES')).toBeInTheDocument();
|
||||
expect(screen.getByText('LAST RECEIVED')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading metric stats')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('metric-highlights-data-points'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('metric-highlights-time-series-total'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('metric-highlights-last-received'),
|
||||
).not.toBeInTheDocument();
|
||||
screen.getByTestId('metric-highlights-loading-state'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -324,22 +324,6 @@ describe('Metadata', () => {
|
||||
expect(editButton2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show section header with disabled edit while loading', () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metadata={null}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Metadata')).toBeInTheDocument();
|
||||
const editButton = screen.getByText('Edit').closest('button');
|
||||
expect(editButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should not allow editing of unit if it is already set', async () => {
|
||||
render(
|
||||
<Metadata
|
||||
|
||||
@@ -24,13 +24,6 @@ jest.mock('react-router-dom', () => ({
|
||||
pathname: `${ROUTES.METRICS_EXPLORER}`,
|
||||
}),
|
||||
}));
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn().mockReturnValue({
|
||||
maxTime: 1700000000000000000,
|
||||
minTime: 1699900000000000000,
|
||||
}),
|
||||
}));
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: jest.fn(),
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import {
|
||||
determineIsMonotonic,
|
||||
@@ -140,7 +139,7 @@ describe('MetricDetails utils', () => {
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.SUM,
|
||||
MetrictypesTypeDTO.sum,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
|
||||
@@ -157,7 +156,7 @@ describe('MetricDetails utils', () => {
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.GAUGE,
|
||||
MetrictypesTypeDTO.gauge,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
|
||||
@@ -174,7 +173,7 @@ describe('MetricDetails utils', () => {
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.GAUGE,
|
||||
MetrictypesTypeDTO.summary,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
|
||||
@@ -191,7 +190,7 @@ describe('MetricDetails utils', () => {
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
MetrictypesTypeDTO.histogram,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
|
||||
@@ -208,7 +207,7 @@ describe('MetricDetails utils', () => {
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
MetrictypesTypeDTO.exponentialhistogram,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
|
||||
|
||||
@@ -34,9 +34,6 @@ export interface MetadataProps {
|
||||
export interface AllAttributesProps {
|
||||
metricName: string;
|
||||
metricType: MetrictypesTypeDTO | undefined;
|
||||
isMonotonic?: boolean;
|
||||
minTime?: number;
|
||||
maxTime?: number;
|
||||
}
|
||||
|
||||
export interface AllAttributesValueProps {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SpaceAggregation, TimeAggregation } from 'api/v5/v5';
|
||||
import { initialQueriesMap, toAttributeType } from 'constants/queryBuilder';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -87,26 +87,15 @@ export function getMetricDetailsQuery(
|
||||
metricType: MetrictypesTypeDTO | undefined,
|
||||
filter?: { key: string; value: string },
|
||||
groupBy?: string,
|
||||
limit?: number,
|
||||
isMonotonic?: boolean,
|
||||
): Query {
|
||||
let timeAggregation;
|
||||
let spaceAggregation;
|
||||
let aggregateOperator;
|
||||
const isNonMonotonicSum =
|
||||
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
|
||||
|
||||
switch (metricType) {
|
||||
case MetrictypesTypeDTO.sum:
|
||||
if (isNonMonotonicSum) {
|
||||
timeAggregation = 'avg';
|
||||
spaceAggregation = 'avg';
|
||||
aggregateOperator = 'avg';
|
||||
} else {
|
||||
timeAggregation = 'rate';
|
||||
spaceAggregation = 'sum';
|
||||
aggregateOperator = 'rate';
|
||||
}
|
||||
timeAggregation = 'rate';
|
||||
spaceAggregation = 'sum';
|
||||
aggregateOperator = 'rate';
|
||||
break;
|
||||
case MetrictypesTypeDTO.gauge:
|
||||
timeAggregation = 'avg';
|
||||
@@ -131,8 +120,6 @@ export function getMetricDetailsQuery(
|
||||
break;
|
||||
}
|
||||
|
||||
const attributeType = toAttributeType(metricType, isMonotonic);
|
||||
|
||||
return {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
@@ -141,8 +128,8 @@ export function getMetricDetailsQuery(
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
aggregateAttribute: {
|
||||
key: metricName,
|
||||
type: attributeType,
|
||||
id: `${metricName}----${attributeType}---string--`,
|
||||
type: metricType ?? '',
|
||||
id: `${metricName}----${metricType}---string--`,
|
||||
dataType: DataTypes.String,
|
||||
},
|
||||
aggregations: [
|
||||
@@ -183,7 +170,6 @@ export function getMetricDetailsQuery(
|
||||
},
|
||||
]
|
||||
: [],
|
||||
...(limit ? { limit } : {}),
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { Info } from 'lucide-react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { MetricsSearchProps } from './types';
|
||||
@@ -24,17 +26,15 @@ function MetricsSearch({
|
||||
onChange(currentQueryFilterExpression);
|
||||
}, [currentQueryFilterExpression, onChange]);
|
||||
|
||||
const handleRunQuery = useCallback(
|
||||
(expression: string): void => {
|
||||
setCurrentQueryFilterExpression(expression);
|
||||
onChange(expression);
|
||||
},
|
||||
[setCurrentQueryFilterExpression, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="metrics-search-container">
|
||||
<div data-testid="qb-search-container" className="qb-search-container">
|
||||
<Tooltip
|
||||
title="Use filters to refine metrics based on attributes. Example: service_name=api - Shows all metrics associated with the API service"
|
||||
placement="right"
|
||||
>
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
<QuerySearch
|
||||
onChange={handleOnChange}
|
||||
dataSource={DataSource.METRICS}
|
||||
@@ -45,9 +45,8 @@ function MetricsSearch({
|
||||
expression: currentQueryFilterExpression,
|
||||
},
|
||||
}}
|
||||
onRun={handleRunQuery}
|
||||
onRun={handleOnChange}
|
||||
showFilterSuggestionsWithoutMetric
|
||||
placeholder="Try metric_name CONTAINS 'http.server' to view all HTTP Server metrics being sent"
|
||||
/>
|
||||
</div>
|
||||
<RunQueryBtn
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
.metrics-search-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
.metrics-search-options {
|
||||
@@ -51,6 +51,10 @@
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
|
||||
.lucide-info {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.query-builder-search-container {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -62,6 +66,8 @@
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
.ant-table-thead > tr > th {
|
||||
padding: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
Querybuildertypesv5OrderByDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import {
|
||||
convertExpressionToFilters,
|
||||
convertFiltersToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -60,13 +61,10 @@ function Summary(): JSX.Element {
|
||||
heatmapView,
|
||||
setHeatmapView,
|
||||
] = useState<MetricsexplorertypesTreemapModeDTO>(
|
||||
MetricsexplorertypesTreemapModeDTO.samples,
|
||||
MetricsexplorertypesTreemapModeDTO.timeseries,
|
||||
);
|
||||
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
useShareBuilderUrl({ defaultValue: initialQueriesMap[DataSource.METRICS] });
|
||||
|
||||
const query = useMemo(() => currentQuery?.builder?.queryData[0], [
|
||||
currentQuery,
|
||||
]);
|
||||
@@ -91,15 +89,6 @@ function Summary(): JSX.Element {
|
||||
setCurrentQueryFilterExpression,
|
||||
] = useState<string>(query?.filter?.expression || '');
|
||||
|
||||
const [appliedFilterExpression, setAppliedFilterExpression] = useState(
|
||||
query?.filter?.expression || '',
|
||||
);
|
||||
|
||||
const queryFilterExpression = useMemo(
|
||||
() => ({ expression: appliedFilterExpression }),
|
||||
[appliedFilterExpression],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.TabChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
@@ -111,6 +100,11 @@ function Summary(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const queryFilterExpression = useMemo(() => {
|
||||
const filters = query.filters || { items: [], op: 'AND' };
|
||||
return convertFiltersToExpression(filters);
|
||||
}, [query.filters]);
|
||||
|
||||
const metricsListQuery: MetricsexplorertypesStatsRequestDTO = useMemo(() => {
|
||||
return {
|
||||
start: convertNanoToMilliseconds(minTime),
|
||||
@@ -193,7 +187,6 @@ function Summary(): JSX.Element {
|
||||
},
|
||||
});
|
||||
setCurrentQueryFilterExpression(expression);
|
||||
setAppliedFilterExpression(expression);
|
||||
setCurrentPage(1);
|
||||
if (expression) {
|
||||
logEvent(MetricsExplorerEvents.FilterApplied, {
|
||||
@@ -312,9 +305,7 @@ function Summary(): JSX.Element {
|
||||
/>
|
||||
{showFullScreenLoading ? (
|
||||
<MetricsLoading />
|
||||
) : isMetricsListDataEmpty &&
|
||||
isMetricsTreeMapDataEmpty &&
|
||||
!appliedFilterExpression ? (
|
||||
) : isMetricsListDataEmpty && isMetricsTreeMapDataEmpty ? (
|
||||
<NoLogs dataSource={DataSource.METRICS} />
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
AggregatorFilter,
|
||||
GroupByFilter,
|
||||
HavingFilter,
|
||||
MetricNameSelector,
|
||||
OperatorsSelect,
|
||||
OrderByFilter,
|
||||
ReduceToFilter,
|
||||
@@ -404,7 +403,7 @@ export const Query = memo(function Query({
|
||||
)}
|
||||
|
||||
<Col flex="auto">
|
||||
<MetricNameSelector
|
||||
<AggregatorFilter
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
query={query}
|
||||
/>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.metric-name-selector {
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--bg-slate-200);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
@@ -1,887 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
MetricsexplorertypesListMetricDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { MetricNameSelector } from './MetricNameSelector';
|
||||
|
||||
const mockUseListMetrics = jest.fn();
|
||||
jest.mock('api/generated/services/metrics', () => ({
|
||||
useListMetrics: (...args: unknown[]): ReturnType<typeof mockUseListMetrics> =>
|
||||
mockUseListMetrics(...args),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDebounce', () => ({
|
||||
__esModule: true,
|
||||
default: <T,>(value: T): T => value,
|
||||
}));
|
||||
|
||||
jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({
|
||||
__esModule: true,
|
||||
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
|
||||
}));
|
||||
|
||||
// Ref lets StatefulMetricQueryHarness wire handleSetQueryData to real state,
|
||||
// while other tests keep the default no-op mock.
|
||||
const handleSetQueryDataRef: {
|
||||
current: (index: number, query: IBuilderQuery) => void;
|
||||
} = {
|
||||
current: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): Record<string, unknown> => ({
|
||||
handleSetQueryData: (index: number, query: IBuilderQuery): void =>
|
||||
handleSetQueryDataRef.current(index, query),
|
||||
handleSetTraceOperatorData: jest.fn(),
|
||||
handleSetFormulaData: jest.fn(),
|
||||
removeQueryBuilderEntityByIndex: jest.fn(),
|
||||
panelType: 'TIME_SERIES',
|
||||
initialDataSource: DataSource.METRICS,
|
||||
currentQuery: {
|
||||
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
|
||||
queryType: 'builder',
|
||||
},
|
||||
setLastUsedQuery: jest.fn(),
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeMetric(
|
||||
overrides: Partial<MetricsexplorertypesListMetricDTO> = {},
|
||||
): MetricsexplorertypesListMetricDTO {
|
||||
return {
|
||||
metricName: 'http_requests_total',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
description: '',
|
||||
temporality: 'cumulative' as never,
|
||||
unit: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeQuery(overrides: Partial<IBuilderQuery> = {}): IBuilderQuery {
|
||||
return {
|
||||
dataSource: DataSource.METRICS,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: '', type: '', dataType: DataTypes.Float64 },
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
filter: { expression: '' },
|
||||
aggregations: [],
|
||||
functions: [],
|
||||
filters: { items: [], op: 'AND' },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
...overrides,
|
||||
} as IBuilderQuery;
|
||||
}
|
||||
|
||||
function returnMetrics(
|
||||
metrics: MetricsexplorertypesListMetricDTO[],
|
||||
overrides: Record<string, unknown> = {},
|
||||
): void {
|
||||
mockUseListMetrics.mockReturnValue({
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
data: { data: { metrics } },
|
||||
queryKey: ['/api/v2/metrics'],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// snippet so tests can assert on them.
|
||||
function MetricQueryHarness({ query }: { query: IBuilderQuery }): JSX.Element {
|
||||
const {
|
||||
handleChangeAggregatorAttribute,
|
||||
operators,
|
||||
spaceAggregationOptions,
|
||||
} = useQueryOperations({
|
||||
query,
|
||||
index: 0,
|
||||
entityVersion: ENTITY_VERSION_V5,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MetricNameSelector
|
||||
query={query}
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
/>
|
||||
<ul data-testid="time-agg-options">
|
||||
{operators.map((op) => (
|
||||
<li key={op.value}>{op.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul data-testid="space-agg-options">
|
||||
{spaceAggregationOptions.map((op) => (
|
||||
<li key={op.value}>{op.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getOptionLabels(testId: string): string[] {
|
||||
const list = screen.getByTestId(testId);
|
||||
const items = within(list).queryAllByRole('listitem');
|
||||
return items.map((el) => el.textContent || '');
|
||||
}
|
||||
|
||||
describe('MetricNameSelector', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
returnMetrics([]);
|
||||
});
|
||||
|
||||
it('shows metric names from API as dropdown options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({ metricName: 'http_requests_total' }),
|
||||
makeMetric({
|
||||
metricName: 'cpu_usage_percent',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'h' } });
|
||||
|
||||
expect(
|
||||
screen.getAllByText('http_requests_total').length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
screen.getAllByText('cpu_usage_percent').length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('retains typed metric name in input after blur', () => {
|
||||
returnMetrics([makeMetric({ metricName: 'http_requests_total' })]);
|
||||
|
||||
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'http_requests_total' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(input).toHaveValue('http_requests_total');
|
||||
});
|
||||
|
||||
it('shows error message when API request fails', () => {
|
||||
mockUseListMetrics.mockReturnValue({
|
||||
isFetching: false,
|
||||
isError: true,
|
||||
data: undefined,
|
||||
queryKey: ['/api/v2/metrics'],
|
||||
});
|
||||
|
||||
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: 'test' } });
|
||||
|
||||
expect(screen.getByText('Failed to load metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading spinner while fetching metrics', () => {
|
||||
mockUseListMetrics.mockReturnValue({
|
||||
isFetching: true,
|
||||
isError: false,
|
||||
data: undefined,
|
||||
queryKey: ['/api/v2/metrics'],
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'test' } });
|
||||
|
||||
expect(container.querySelector('.ant-spin-spinning')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selecting a metric type updates the aggregation options', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
returnMetrics([]);
|
||||
});
|
||||
|
||||
it('Sum metric shows Rate/Increase time options and Sum/Avg/Min/Max space options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'http_requests_total',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'http_requests_total' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual(['Rate', 'Increase']);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Gauge metric shows Latest/Sum/Avg/Min/Max/Count/Count Distinct time options and Sum/Avg/Min/Max space options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'cpu_usage_percent',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'cpu_usage_percent' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual([
|
||||
'Latest',
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
'Count',
|
||||
'Count Distinct',
|
||||
]);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
]);
|
||||
});
|
||||
|
||||
it('non-monotonic Sum metric is treated as Gauge', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'active_connections',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'active_connections' },
|
||||
});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual([
|
||||
'Latest',
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
'Count',
|
||||
'Count Distinct',
|
||||
]);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Histogram metric shows no time options and P50–P99 space options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'request_duration_seconds',
|
||||
type: MetrictypesTypeDTO.histogram,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'request_duration_seconds' },
|
||||
});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual([]);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'P50',
|
||||
'P75',
|
||||
'P90',
|
||||
'P95',
|
||||
'P99',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ExponentialHistogram metric shows no time options and P50–P99 space options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'request_duration_exp',
|
||||
type: MetrictypesTypeDTO.exponentialhistogram,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'request_duration_exp' },
|
||||
});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual([]);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'P50',
|
||||
'P75',
|
||||
'P90',
|
||||
'P95',
|
||||
'P99',
|
||||
]);
|
||||
});
|
||||
|
||||
it('unknown metric (typed name not in API results) shows all time and space options', () => {
|
||||
returnMetrics([makeMetric({ metricName: 'known_metric' })]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'unknown_metric' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual([
|
||||
'Max',
|
||||
'Min',
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Count',
|
||||
'Rate',
|
||||
'Increase',
|
||||
]);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
'P50',
|
||||
'P75',
|
||||
'P90',
|
||||
'P95',
|
||||
'P99',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// these tests require the previous state, so we setup it to
|
||||
// tracks previousMetricInfo across metric selections
|
||||
function StatefulMetricQueryHarness({
|
||||
initialQuery,
|
||||
}: {
|
||||
initialQuery: IBuilderQuery;
|
||||
}): JSX.Element {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
|
||||
useEffect(() => {
|
||||
handleSetQueryDataRef.current = (
|
||||
_index: number,
|
||||
newQuery: IBuilderQuery,
|
||||
): void => {
|
||||
setQuery(newQuery);
|
||||
};
|
||||
return (): void => {
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
handleChangeAggregatorAttribute,
|
||||
operators,
|
||||
spaceAggregationOptions,
|
||||
} = useQueryOperations({
|
||||
query,
|
||||
index: 0,
|
||||
entityVersion: ENTITY_VERSION_V5,
|
||||
});
|
||||
|
||||
const currentAggregation = query.aggregations?.[0] as MetricAggregation;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MetricNameSelector
|
||||
query={query}
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
/>
|
||||
<ul data-testid="time-agg-options">
|
||||
{operators.map((op) => (
|
||||
<li key={op.value}>{op.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul data-testid="space-agg-options">
|
||||
{spaceAggregationOptions.map((op) => (
|
||||
<li key={op.value}>{op.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div data-testid="selected-time-agg">
|
||||
{currentAggregation?.timeAggregation || ''}
|
||||
</div>
|
||||
<div data-testid="selected-space-agg">
|
||||
{currentAggregation?.spaceAggregation || ''}
|
||||
</div>
|
||||
<div data-testid="selected-metric-name">
|
||||
{currentAggregation?.metricName || ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('switching between metrics of the same type preserves aggregation settings', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
returnMetrics([]);
|
||||
});
|
||||
|
||||
it('Sum: preserves non-default increase/avg when switching to another Sum metric', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'metric_a',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'metric_b',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'metric_a',
|
||||
type: ATTRIBUTE_TYPES.SUM,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: 'increase',
|
||||
spaceAggregation: 'avg',
|
||||
metricName: 'metric_a',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('increase');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'metric_b' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('increase');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'metric_b',
|
||||
);
|
||||
});
|
||||
|
||||
it('Gauge: preserves non-default min/max when switching to another Gauge metric', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'cpu_usage',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'mem_usage',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'cpu_usage',
|
||||
type: ATTRIBUTE_TYPES.GAUGE,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: 'min',
|
||||
spaceAggregation: 'max',
|
||||
metricName: 'cpu_usage',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'mem_usage' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'mem_usage',
|
||||
);
|
||||
});
|
||||
|
||||
it('Histogram: preserves non-default p99 when switching to another Histogram metric', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'req_duration',
|
||||
type: MetrictypesTypeDTO.histogram,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'db_latency',
|
||||
type: MetrictypesTypeDTO.histogram,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'req_duration',
|
||||
type: ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: '',
|
||||
spaceAggregation: 'p99',
|
||||
metricName: 'req_duration',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p99');
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'db_latency' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p99');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'db_latency',
|
||||
);
|
||||
});
|
||||
|
||||
it('ExponentialHistogram: preserves non-default p75 when switching to another ExponentialHistogram metric', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'exp_hist_a',
|
||||
type: MetrictypesTypeDTO.exponentialhistogram,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'exp_hist_b',
|
||||
type: MetrictypesTypeDTO.exponentialhistogram,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'exp_hist_a',
|
||||
type: ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: '',
|
||||
spaceAggregation: 'p75',
|
||||
metricName: 'exp_hist_a',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p75');
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'exp_hist_b' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p75');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'exp_hist_b',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('switching to a different metric type resets aggregation to new defaults', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
returnMetrics([]);
|
||||
});
|
||||
|
||||
it('Sum to Gauge: resets from increase/avg to the Gauge defaults avg/avg', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'sum_metric',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'gauge_metric',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'sum_metric',
|
||||
type: ATTRIBUTE_TYPES.SUM,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: 'increase',
|
||||
spaceAggregation: 'avg',
|
||||
metricName: 'sum_metric',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'gauge_metric' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('avg');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'gauge_metric',
|
||||
);
|
||||
});
|
||||
|
||||
it('Gauge to Histogram: resets from min/max to the Histogram defaults (no time, p90 space)', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'gauge_metric',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'hist_metric',
|
||||
type: MetrictypesTypeDTO.histogram,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'gauge_metric',
|
||||
type: ATTRIBUTE_TYPES.GAUGE,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: 'min',
|
||||
spaceAggregation: 'max',
|
||||
metricName: 'gauge_metric',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'hist_metric' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p90');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'hist_metric',
|
||||
);
|
||||
});
|
||||
|
||||
it('Histogram to Sum: resets from p99 to the Sum defaults rate/sum', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'hist_metric',
|
||||
type: MetrictypesTypeDTO.histogram,
|
||||
}),
|
||||
makeMetric({
|
||||
metricName: 'sum_metric',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'hist_metric',
|
||||
type: ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: '',
|
||||
spaceAggregation: 'p99',
|
||||
metricName: 'hist_metric',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'sum_metric' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('rate');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('sum');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'sum_metric',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('typed metric not in search results is committed with unknown defaults', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
returnMetrics([]);
|
||||
});
|
||||
|
||||
it('Gauge to unknown metric: resets from Gauge aggregations to unknown defaults (avg/avg)', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'cpu_usage',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<StatefulMetricQueryHarness
|
||||
initialQuery={makeQuery({
|
||||
aggregateAttribute: {
|
||||
key: 'cpu_usage',
|
||||
type: ATTRIBUTE_TYPES.GAUGE,
|
||||
dataType: DataTypes.Float64,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: 'min',
|
||||
spaceAggregation: 'max',
|
||||
metricName: 'cpu_usage',
|
||||
temporality: '',
|
||||
},
|
||||
] as MetricAggregation[],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'unknown_metric' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
// Metric not in search results is committed with empty type resets to unknown defaults
|
||||
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('avg');
|
||||
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
|
||||
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
|
||||
'unknown_metric',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Summary metric type is treated as Gauge', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleSetQueryDataRef.current = jest.fn();
|
||||
returnMetrics([]);
|
||||
});
|
||||
|
||||
it('selecting a Summary metric shows Gauge aggregation options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'rpc_duration_summary',
|
||||
type: MetrictypesTypeDTO.summary,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'rpc_duration_summary' },
|
||||
});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toEqual([
|
||||
'Latest',
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
'Count',
|
||||
'Count Distinct',
|
||||
]);
|
||||
expect(getOptionLabels('space-agg-options')).toEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,266 +0,0 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { AutoComplete, Spin, Typography } from 'antd';
|
||||
import { useListMetrics } from 'api/generated/services/metrics';
|
||||
import { MetricsexplorertypesListMetricDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ATTRIBUTE_TYPES, toAttributeType } from 'constants/queryBuilder';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { ExtendedSelectOption } from 'types/common/select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
|
||||
import './MetricNameSelector.styles.scss';
|
||||
|
||||
export type MetricNameSelectorProps = {
|
||||
query: IBuilderQuery;
|
||||
onChange: (value: BaseAutocompleteData, isEditMode?: boolean) => void;
|
||||
disabled?: boolean;
|
||||
defaultValue?: string;
|
||||
onSelect?: (value: BaseAutocompleteData) => void;
|
||||
signalSource?: 'meter' | '';
|
||||
};
|
||||
|
||||
function getAttributeType(
|
||||
metric: MetricsexplorertypesListMetricDTO,
|
||||
): ATTRIBUTE_TYPES | '' {
|
||||
return toAttributeType(metric.type, metric.isMonotonic);
|
||||
}
|
||||
|
||||
function createAutocompleteData(
|
||||
metricName: string,
|
||||
type: string,
|
||||
): BaseAutocompleteData {
|
||||
return { key: metricName, type, dataType: DataTypes.Float64 };
|
||||
}
|
||||
|
||||
// N.B on the metric name selector behaviour.
|
||||
//
|
||||
// Metric aggregation options resolution:
|
||||
// The component maintains a ref (metricsRef) of the latest API results.
|
||||
// When the user commits a metric name (via dropdown select, blur, or Cmd+Enter),
|
||||
// resolveMetricFromText looks up the metric in metricsRef to determine its type
|
||||
// (Sum, Gauge, Histogram, etc.). If the metric isn't found (e.g. the user typed
|
||||
// a name before the debounced search returned), the type is empty and downstream
|
||||
// treats it as unknown.
|
||||
//
|
||||
// Selection handling:
|
||||
// - Dropdown select: user picks from the dropdown; type is always resolved
|
||||
// since the option came from the current API results.
|
||||
// - Blur: user typed a name and tabbed/clicked away without selecting from
|
||||
// the dropdown. If the name differs from the current metric, it's resolved
|
||||
// and committed. If the input is empty, it resets to the current metric name.
|
||||
// - Cmd/Ctrl+Enter: resolves the typed name and commits it using flushSync
|
||||
// so the state update is processed synchronously before QueryBuilderV2's
|
||||
// onKeyDownCapture fires handleRunQuery. Uses document-level capture phase
|
||||
// to run before React's root-level event dispatch. However, there is still one
|
||||
// need to be handled here. TODO(srikanthccv): enter before n/w req completion
|
||||
//
|
||||
// Edit mode:
|
||||
// When a saved query is loaded, the metric name may be set via aggregations
|
||||
// but aggregateAttribute.type may be missing. Once the API returns metric data,
|
||||
// the component calls onChange with isEditMode=true to backfill the type without
|
||||
// resetting aggregation options.
|
||||
//
|
||||
// Signal source:
|
||||
// When signalSource is 'meter', the API is filtered to meter metrics only.
|
||||
// Changing signalSource clears the input and search text.
|
||||
|
||||
export const MetricNameSelector = memo(function MetricNameSelector({
|
||||
query,
|
||||
onChange,
|
||||
disabled,
|
||||
defaultValue,
|
||||
onSelect,
|
||||
signalSource,
|
||||
}: MetricNameSelectorProps): JSX.Element {
|
||||
const currentMetricName =
|
||||
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
|
||||
query.aggregateAttribute?.key ||
|
||||
'';
|
||||
|
||||
const [inputValue, setInputValue] = useState<string>(
|
||||
currentMetricName || defaultValue || '',
|
||||
);
|
||||
const [searchText, setSearchText] = useState<string>(currentMetricName);
|
||||
|
||||
const metricsRef = useRef<MetricsexplorertypesListMetricDTO[]>([]);
|
||||
const selectedFromDropdownRef = useRef(false);
|
||||
const prevSignalSourceRef = useRef(signalSource);
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(currentMetricName || defaultValue || '');
|
||||
}, [defaultValue, currentMetricName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevSignalSourceRef.current !== signalSource) {
|
||||
prevSignalSourceRef.current = signalSource;
|
||||
setSearchText('');
|
||||
setInputValue('');
|
||||
}
|
||||
}, [signalSource]);
|
||||
|
||||
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
|
||||
|
||||
const { isFetching, isError, data: listMetricsData } = useListMetrics(
|
||||
{
|
||||
searchText: debouncedValue,
|
||||
limit: 100,
|
||||
source: signalSource || undefined,
|
||||
} as Record<string, unknown>,
|
||||
{
|
||||
query: {
|
||||
keepPreviousData: false,
|
||||
retry: 2,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const metrics = useMemo(() => listMetricsData?.data?.metrics ?? [], [
|
||||
listMetricsData,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
metricsRef.current = metrics;
|
||||
}, [metrics]);
|
||||
|
||||
const optionsData = useMemo((): ExtendedSelectOption[] => {
|
||||
if (!metrics.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return metrics.map((metric) => ({
|
||||
label: (
|
||||
<OptionRenderer
|
||||
label={metric.metricName}
|
||||
value={metric.metricName}
|
||||
dataType={DataTypes.Float64}
|
||||
type={getAttributeType(metric) || ''}
|
||||
/>
|
||||
),
|
||||
value: metric.metricName,
|
||||
key: metric.metricName,
|
||||
}));
|
||||
}, [metrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const metricName = (query.aggregations?.[0] as MetricAggregation)?.metricName;
|
||||
const hasAggregateAttributeType = query.aggregateAttribute?.type;
|
||||
|
||||
if (metricName && !hasAggregateAttributeType && metrics.length > 0) {
|
||||
const found = metrics.find((m) => m.metricName === metricName);
|
||||
if (found) {
|
||||
onChange(
|
||||
createAutocompleteData(found.metricName, getAttributeType(found)),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [metrics, query.aggregations, query.aggregateAttribute?.type, onChange]);
|
||||
|
||||
const resolveMetricFromText = useCallback(
|
||||
(text: string): BaseAutocompleteData => {
|
||||
const found = metricsRef.current.find((m) => m.metricName === text);
|
||||
if (found) {
|
||||
return createAutocompleteData(found.metricName, getAttributeType(found));
|
||||
}
|
||||
return createAutocompleteData(text, '');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const placeholder = useMemo(() => {
|
||||
if (signalSource === 'meter') {
|
||||
return 'Search for a meter metric...';
|
||||
}
|
||||
return 'Search for a metric...';
|
||||
}, [signalSource]);
|
||||
|
||||
const handleChange = useCallback((value: string): void => {
|
||||
setInputValue(value);
|
||||
}, []);
|
||||
|
||||
const handleSearch = useCallback((value: string): void => {
|
||||
setSearchText(value);
|
||||
selectedFromDropdownRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value: string): void => {
|
||||
selectedFromDropdownRef.current = true;
|
||||
const resolved = resolveMetricFromText(value);
|
||||
onChange(resolved);
|
||||
if (onSelect) {
|
||||
onSelect(resolved);
|
||||
}
|
||||
setSearchText('');
|
||||
},
|
||||
[onChange, onSelect, resolveMetricFromText],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (selectedFromDropdownRef.current) {
|
||||
selectedFromDropdownRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const typedValue = inputValue?.trim() || '';
|
||||
if (typedValue && typedValue !== currentMetricName) {
|
||||
onChange(resolveMetricFromText(typedValue));
|
||||
} else if (!typedValue && currentMetricName) {
|
||||
setInputValue(currentMetricName);
|
||||
}
|
||||
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
const typedValue = inputValue?.trim() || '';
|
||||
if (typedValue && typedValue !== currentMetricName) {
|
||||
flushSync(() => {
|
||||
onChange(resolveMetricFromText(typedValue));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return (): void => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
className="metric-name-selector"
|
||||
getPopupContainer={popupContainer}
|
||||
style={selectStyle}
|
||||
filterOption={false}
|
||||
placeholder={placeholder}
|
||||
onSearch={handleSearch}
|
||||
onChange={handleChange}
|
||||
notFoundContent={
|
||||
isFetching ? (
|
||||
<Spin size="small" />
|
||||
) : isError ? (
|
||||
<Typography.Text type="danger" style={{ fontSize: 12 }}>
|
||||
Failed to load metrics
|
||||
</Typography.Text>
|
||||
) : null
|
||||
}
|
||||
options={optionsData}
|
||||
value={inputValue}
|
||||
onBlur={handleBlur}
|
||||
onSelect={handleSelect}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { MetricNameSelectorProps } from './MetricNameSelector';
|
||||
export { MetricNameSelector } from './MetricNameSelector';
|
||||
@@ -2,7 +2,6 @@ export { AggregatorFilter } from './AggregatorFilter';
|
||||
export { BuilderUnitsFilter } from './BuilderUnitsFilter';
|
||||
export { GroupByFilter } from './GroupByFilter';
|
||||
export { HavingFilter } from './HavingFilter';
|
||||
export { MetricNameSelector } from './MetricNameSelector';
|
||||
export { OperatorsSelect } from './OperatorsSelect';
|
||||
export { OrderByFilter } from './OrderByFilter';
|
||||
export { ReduceToFilter } from './ReduceToFilter';
|
||||
|
||||
@@ -257,9 +257,7 @@ function TimeSeriesView({
|
||||
chartData[0]?.length === 0 &&
|
||||
!isLoading &&
|
||||
!isError &&
|
||||
dataSource === DataSource.METRICS && (
|
||||
<EmptyMetricsSearch hasQueryResult={data !== undefined} />
|
||||
)}
|
||||
dataSource === DataSource.METRICS && <EmptyMetricsSearch />}
|
||||
|
||||
{!isLoading &&
|
||||
!isError &&
|
||||
|
||||
@@ -248,12 +248,19 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
);
|
||||
|
||||
const handleChangeAggregatorAttribute = useCallback(
|
||||
(value: BaseAutocompleteData, isEditMode?: boolean): void => {
|
||||
(
|
||||
value: BaseAutocompleteData,
|
||||
isEditMode?: boolean,
|
||||
attributeKeys?: BaseAutocompleteData[],
|
||||
): void => {
|
||||
const newQuery: IBuilderQuery = {
|
||||
...query,
|
||||
aggregateAttribute: value,
|
||||
};
|
||||
|
||||
const getAttributeKeyFromMetricName = (metricName: string): string =>
|
||||
attributeKeys?.find((key) => key.key === metricName)?.type || '';
|
||||
|
||||
if (
|
||||
newQuery.dataSource === DataSource.METRICS &&
|
||||
entityVersion === ENTITY_VERSION_V4
|
||||
@@ -304,7 +311,9 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
// Get current metric info
|
||||
const currentMetricType = newQuery.aggregateAttribute?.type || '';
|
||||
|
||||
const prevMetricType = previousMetricInfo?.type || '';
|
||||
const prevMetricType = previousMetricInfo?.type
|
||||
? previousMetricInfo.type
|
||||
: getAttributeKeyFromMetricName(previousMetricInfo?.name || '');
|
||||
|
||||
// Check if metric type has changed by comparing with tracked previous values
|
||||
const metricTypeChanged =
|
||||
@@ -365,7 +374,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
|
||||
// Handled query with unknown metric to avoid 400 and 500 errors
|
||||
// With metric value typed and not available then - time - 'avg', space - 'avg'
|
||||
// If not typed - time - 'avg', space - 'sum'
|
||||
// If not typed - time - 'rate', space - 'sum', op - 'count'
|
||||
if (isEmpty(newQuery.aggregateAttribute?.type)) {
|
||||
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
|
||||
newQuery.aggregations = [
|
||||
@@ -379,7 +388,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
} else {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
timeAggregation: MetricAggregateOperator.COUNT,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
@@ -399,29 +408,6 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Override with safe defaults when metric type is unknown to avoid 400/500 errors
|
||||
if (isEmpty(newQuery.aggregateAttribute?.type)) {
|
||||
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.AVG,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY scripts/generate-permissions-type
|
||||
export default {
|
||||
status: 'success',
|
||||
data: {
|
||||
resources: [
|
||||
{
|
||||
name: 'dashboard',
|
||||
type: 'metaresource',
|
||||
},
|
||||
{
|
||||
name: 'dashboards',
|
||||
type: 'metaresources',
|
||||
},
|
||||
],
|
||||
relations: {
|
||||
create: ['metaresources'],
|
||||
delete: ['user', 'role', 'organization', 'metaresource'],
|
||||
list: ['metaresources'],
|
||||
read: ['user', 'role', 'organization', 'metaresource'],
|
||||
update: ['user', 'role', 'organization', 'metaresource'],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -1,57 +0,0 @@
|
||||
import permissionsType from './permissions.type';
|
||||
import { ObjectSeparator } from './utils';
|
||||
|
||||
type PermissionsData = typeof permissionsType.data;
|
||||
export type Resource = PermissionsData['resources'][number];
|
||||
export type ResourceName = Resource['name'];
|
||||
export type ResourceType = Resource['type'];
|
||||
|
||||
type RelationsByType = PermissionsData['relations'];
|
||||
|
||||
type ResourceTypeMap = {
|
||||
[K in ResourceName]: Extract<Resource, { name: K }>['type'];
|
||||
};
|
||||
|
||||
type RelationName = keyof RelationsByType;
|
||||
|
||||
type ResourcesForRelation<R extends RelationName> = Extract<
|
||||
Resource,
|
||||
{ type: RelationsByType[R][number] }
|
||||
>['name'];
|
||||
|
||||
type IsPluralResource<
|
||||
R extends ResourceName
|
||||
> = ResourceTypeMap[R] extends 'metaresources' ? true : false;
|
||||
|
||||
type ObjectForResource<R extends ResourceName> = R extends infer U
|
||||
? U extends ResourceName
|
||||
? IsPluralResource<U> extends true
|
||||
? U
|
||||
: `${U}${typeof ObjectSeparator}${string}`
|
||||
: never
|
||||
: never;
|
||||
|
||||
type RelationToObject<R extends RelationName> = ObjectForResource<
|
||||
ResourcesForRelation<R>
|
||||
>;
|
||||
|
||||
type AllRelations = RelationName;
|
||||
|
||||
export type AuthZRelation = AllRelations;
|
||||
export type AuthZResource = ResourceName;
|
||||
export type AuthZObject<R extends AuthZRelation> = RelationToObject<R>;
|
||||
|
||||
export type BrandedPermission = string & { __brandedPermission: true };
|
||||
|
||||
export type AuthZCheckResponse = Record<
|
||||
BrandedPermission,
|
||||
{
|
||||
isGranted: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
export type UseAuthZResult = {
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
permissions: AuthZCheckResponse | null;
|
||||
};
|
||||
@@ -1,496 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
|
||||
import { BrandedPermission } from './types';
|
||||
import { useAuthZ } from './useAuthZ';
|
||||
import { buildPermission } from './utils';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL || '';
|
||||
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
|
||||
|
||||
function authzMockResponse(
|
||||
payload: AuthtypesTransactionDTO[],
|
||||
authorizedByIndex: boolean[],
|
||||
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
|
||||
return {
|
||||
data: payload.map((txn, i) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: authorizedByIndex[i] ?? false,
|
||||
})),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
const wrapper = ({ children }: { children: ReactElement }): ReactElement => (
|
||||
<AllTheProviders>{children}</AllTheProviders>
|
||||
);
|
||||
|
||||
describe('useAuthZ', () => {
|
||||
it('should fetch and return permissions successfully', async () => {
|
||||
const permission1 = buildPermission('read', 'dashboard:*');
|
||||
const permission2 = buildPermission('update', 'dashboard:123');
|
||||
|
||||
const expectedResponse = {
|
||||
[permission1]: {
|
||||
isGranted: true,
|
||||
},
|
||||
[permission2]: {
|
||||
isGranted: false,
|
||||
},
|
||||
};
|
||||
|
||||
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, false])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.permissions).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.permissions).toEqual(expectedResponse);
|
||||
});
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
const permission = buildPermission('read', 'dashboard:*');
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuthZ([permission]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).not.toBeNull();
|
||||
expect(result.current.permissions).toBeNull();
|
||||
});
|
||||
|
||||
it('should refetch when permissions array changes', async () => {
|
||||
const permission1 = buildPermission('read', 'dashboard:*');
|
||||
const permission2 = buildPermission('update', 'dashboard:123');
|
||||
const permission3 = buildPermission('delete', 'dashboard:456');
|
||||
|
||||
let requestCount = 0;
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount++;
|
||||
const payload = await req.json();
|
||||
|
||||
if (payload.length === 1) {
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}
|
||||
|
||||
const authorized = payload.map(
|
||||
(txn: { relation: string }) =>
|
||||
txn.relation === 'read' || txn.relation === 'delete',
|
||||
);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, authorized)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook<
|
||||
ReturnType<typeof useAuthZ>,
|
||||
BrandedPermission[]
|
||||
>((permissions) => useAuthZ(permissions), {
|
||||
wrapper,
|
||||
initialProps: [permission1],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(1);
|
||||
expect(result.current.permissions).toEqual({
|
||||
[permission1]: {
|
||||
isGranted: true,
|
||||
},
|
||||
});
|
||||
|
||||
rerender([permission1, permission2, permission3]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(result.current.permissions).toEqual({
|
||||
[permission1]: {
|
||||
isGranted: true,
|
||||
},
|
||||
[permission2]: {
|
||||
isGranted: false,
|
||||
},
|
||||
[permission3]: {
|
||||
isGranted: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not refetch when permissions array order changes but content is the same', async () => {
|
||||
const permission1 = buildPermission('read', 'dashboard:*');
|
||||
const permission2 = buildPermission('update', 'dashboard:123');
|
||||
|
||||
let requestCount = 0;
|
||||
|
||||
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, [true, false])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook<
|
||||
ReturnType<typeof useAuthZ>,
|
||||
BrandedPermission[]
|
||||
>((permissions) => useAuthZ(permissions), {
|
||||
wrapper,
|
||||
initialProps: [permission1, permission2],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(1);
|
||||
|
||||
rerender([permission2, permission1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle empty permissions array', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json({ data: [], status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuthZ([]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.permissions).toEqual({});
|
||||
});
|
||||
|
||||
it('should send correct payload format to API', async () => {
|
||||
const permission1 = buildPermission('read', 'dashboard:*');
|
||||
const permission2 = buildPermission('update', 'dashboard:123');
|
||||
|
||||
let receivedPayload: any = null;
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
receivedPayload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(receivedPayload, [true, false])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(receivedPayload).toHaveLength(2);
|
||||
expect(receivedPayload[0]).toMatchObject({
|
||||
relation: 'read',
|
||||
object: {
|
||||
resource: { name: 'dashboard', type: 'metaresource' },
|
||||
selector: '*',
|
||||
},
|
||||
});
|
||||
expect(receivedPayload[1]).toMatchObject({
|
||||
relation: 'update',
|
||||
object: {
|
||||
resource: { name: 'dashboard', type: 'metaresource' },
|
||||
selector: '123',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should batch multiple hooks into single flight request', async () => {
|
||||
const permission1 = buildPermission('read', 'dashboard:*');
|
||||
const permission2 = buildPermission('update', 'dashboard:123');
|
||||
const permission3 = buildPermission('delete', 'dashboard:456');
|
||||
|
||||
let requestCount = 0;
|
||||
const receivedPayloads: any[] = [];
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount++;
|
||||
const payload = await req.json();
|
||||
receivedPayloads.push(payload);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [true, false, true])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result: result1 } = renderHook(() => useAuthZ([permission1]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const { result: result2 } = renderHook(() => useAuthZ([permission2]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const { result: result3 } = renderHook(() => useAuthZ([permission3]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result1.current.isLoading).toBe(false);
|
||||
expect(result2.current.isLoading).toBe(false);
|
||||
expect(result3.current.isLoading).toBe(false);
|
||||
},
|
||||
{ timeout: 200 },
|
||||
);
|
||||
|
||||
expect(requestCount).toBe(1);
|
||||
expect(receivedPayloads).toHaveLength(1);
|
||||
expect(receivedPayloads[0]).toHaveLength(3);
|
||||
expect(receivedPayloads[0][0]).toMatchObject({
|
||||
relation: 'read',
|
||||
object: {
|
||||
resource: { name: 'dashboard', type: 'metaresource' },
|
||||
selector: '*',
|
||||
},
|
||||
});
|
||||
expect(receivedPayloads[0][1]).toMatchObject({
|
||||
relation: 'update',
|
||||
object: { resource: { name: 'dashboard' }, selector: '123' },
|
||||
});
|
||||
expect(receivedPayloads[0][2]).toMatchObject({
|
||||
relation: 'delete',
|
||||
object: { resource: { name: 'dashboard' }, selector: '456' },
|
||||
});
|
||||
|
||||
expect(result1.current.permissions).toEqual({
|
||||
[permission1]: { isGranted: true },
|
||||
});
|
||||
expect(result2.current.permissions).toEqual({
|
||||
[permission2]: { isGranted: false },
|
||||
});
|
||||
expect(result3.current.permissions).toEqual({
|
||||
[permission3]: { isGranted: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should create separate batches for calls after single flight window', async () => {
|
||||
const permission1 = buildPermission('read', 'dashboard:*');
|
||||
const permission2 = buildPermission('update', 'dashboard:123');
|
||||
const permission3 = buildPermission('delete', 'dashboard:456');
|
||||
|
||||
let requestCount = 0;
|
||||
const receivedPayloads: any[] = [];
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount++;
|
||||
const payload = await req.json();
|
||||
receivedPayloads.push(payload);
|
||||
const authorized = payload.length === 1 ? [true] : [false, true];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, authorized)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result: result1 } = renderHook(() => useAuthZ([permission1]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result1.current.isLoading).toBe(false);
|
||||
},
|
||||
{ timeout: 200 },
|
||||
);
|
||||
|
||||
expect(requestCount).toBe(1);
|
||||
expect(receivedPayloads[0]).toHaveLength(1);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const { result: result2 } = renderHook(() => useAuthZ([permission2]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const { result: result3 } = renderHook(() => useAuthZ([permission3]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result2.current.isLoading).toBe(false);
|
||||
expect(result3.current.isLoading).toBe(false);
|
||||
},
|
||||
{ timeout: 200 },
|
||||
);
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(receivedPayloads).toHaveLength(2);
|
||||
expect(receivedPayloads[1]).toHaveLength(2);
|
||||
expect(receivedPayloads[1][0]).toMatchObject({
|
||||
relation: 'update',
|
||||
object: { resource: { name: 'dashboard' }, selector: '123' },
|
||||
});
|
||||
expect(receivedPayloads[1][1]).toMatchObject({
|
||||
relation: 'delete',
|
||||
object: { resource: { name: 'dashboard' }, selector: '456' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should map permissions correctly when API returns response out of order', async () => {
|
||||
const permission1 = buildPermission('read', 'dashboard:*');
|
||||
const permission2 = buildPermission('update', 'dashboard:123');
|
||||
const permission3 = buildPermission('delete', 'dashboard:456');
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
const reversed = [...payload].reverse();
|
||||
const authorizedByReversed = [true, false, true];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: reversed.map((txn: any, i: number) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: authorizedByReversed[i],
|
||||
})),
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useAuthZ([permission1, permission2, permission3]),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.permissions).toEqual({
|
||||
[permission1]: { isGranted: true },
|
||||
[permission2]: { isGranted: false },
|
||||
[permission3]: { isGranted: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not leak state between separate batches', async () => {
|
||||
const permission1 = buildPermission('read', 'dashboard:*');
|
||||
const permission2 = buildPermission('update', 'dashboard:123');
|
||||
|
||||
let requestCount = 0;
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount++;
|
||||
const payload = await req.json();
|
||||
const authorized = payload.map(
|
||||
(txn: { relation: string }) => txn.relation === 'read',
|
||||
);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, authorized)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result: result1 } = renderHook(() => useAuthZ([permission1]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result1.current.isLoading).toBe(false);
|
||||
},
|
||||
{ timeout: 200 },
|
||||
);
|
||||
|
||||
expect(requestCount).toBe(1);
|
||||
expect(result1.current.permissions).toEqual({
|
||||
[permission1]: { isGranted: true },
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const { result: result2 } = renderHook(() => useAuthZ([permission2]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result2.current.isLoading).toBe(false);
|
||||
},
|
||||
{ timeout: 200 },
|
||||
);
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(result1.current.permissions).toEqual({
|
||||
[permission1]: { isGranted: true },
|
||||
});
|
||||
expect(result2.current.permissions).toEqual({
|
||||
[permission2]: { isGranted: false },
|
||||
});
|
||||
expect(result1.current.permissions).not.toHaveProperty(permission2);
|
||||
expect(result2.current.permissions).not.toHaveProperty(permission1);
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { authzCheck } from 'api/generated/services/authz';
|
||||
import type {
|
||||
AuthtypesObjectDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { AuthZCheckResponse, BrandedPermission, UseAuthZResult } from './types';
|
||||
import {
|
||||
gettableTransactionToPermission,
|
||||
permissionToTransactionDto,
|
||||
} from './utils';
|
||||
|
||||
let ctx: Promise<AuthZCheckResponse> | null;
|
||||
let pendingPermissions: BrandedPermission[] = [];
|
||||
const SINGLE_FLIGHT_WAIT_TIME_MS = 50;
|
||||
const AUTHZ_CACHE_TIME = 20_000;
|
||||
|
||||
function dispatchPermission(
|
||||
permission: BrandedPermission,
|
||||
): Promise<AuthZCheckResponse> {
|
||||
pendingPermissions.push(permission);
|
||||
|
||||
if (!ctx) {
|
||||
let resolve: (v: AuthZCheckResponse) => void, reject: (reason?: any) => void;
|
||||
ctx = new Promise<AuthZCheckResponse>((r, re) => {
|
||||
resolve = r;
|
||||
reject = re;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
const copiedPermissions = pendingPermissions.slice();
|
||||
pendingPermissions = [];
|
||||
ctx = null;
|
||||
|
||||
fetchManyPermissions(copiedPermissions).then(resolve).catch(reject);
|
||||
}, SINGLE_FLIGHT_WAIT_TIME_MS);
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
async function fetchManyPermissions(
|
||||
permissions: BrandedPermission[],
|
||||
): Promise<AuthZCheckResponse> {
|
||||
const payload: AuthtypesTransactionDTO[] = permissions.map((permission) => {
|
||||
const dto = permissionToTransactionDto(permission);
|
||||
const object: AuthtypesObjectDTO = {
|
||||
resource: {
|
||||
name: dto.object.resource.name,
|
||||
type: dto.object.resource.type,
|
||||
},
|
||||
selector: dto.object.selector,
|
||||
};
|
||||
return { relation: dto.relation, object };
|
||||
});
|
||||
|
||||
const { data } = await authzCheck(payload);
|
||||
|
||||
const fromApi = (data ?? []).reduce<AuthZCheckResponse>((acc, item) => {
|
||||
const permission = gettableTransactionToPermission(item);
|
||||
acc[permission] = { isGranted: !!item.authorized };
|
||||
return acc;
|
||||
}, {} as AuthZCheckResponse);
|
||||
|
||||
return permissions.reduce<AuthZCheckResponse>((acc, permission) => {
|
||||
acc[permission] = fromApi[permission] ?? { isGranted: false };
|
||||
return acc;
|
||||
}, {} as AuthZCheckResponse);
|
||||
}
|
||||
|
||||
export function useAuthZ(permissions: BrandedPermission[]): UseAuthZResult {
|
||||
const queryResults = useQueries(
|
||||
permissions.map((permission) => {
|
||||
return {
|
||||
queryKey: ['authz', permission],
|
||||
cacheTime: AUTHZ_CACHE_TIME,
|
||||
refetchOnMount: false,
|
||||
refetchIntervalInBackground: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
queryFn: async (): Promise<AuthZCheckResponse> => {
|
||||
const response = await dispatchPermission(permission);
|
||||
|
||||
return {
|
||||
[permission]: {
|
||||
isGranted: response[permission].isGranted,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const isLoading = useMemo(() => queryResults.some((q) => q.isLoading), [
|
||||
queryResults,
|
||||
]);
|
||||
const error = useMemo(
|
||||
() =>
|
||||
!isLoading
|
||||
? (queryResults.find((q) => !!q.error)?.error as Error) || null
|
||||
: null,
|
||||
[isLoading, queryResults],
|
||||
);
|
||||
const data = useMemo(() => {
|
||||
if (isLoading || error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return queryResults.reduce((acc, q) => {
|
||||
if (!q.data) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(q.data)) {
|
||||
acc[key as BrandedPermission] = value;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as AuthZCheckResponse);
|
||||
}, [isLoading, error, queryResults]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
permissions: data ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { AuthtypesTransactionDTO } from '../../api/generated/services/sigNoz.schemas';
|
||||
import permissionsType from './permissions.type';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
AuthZResource,
|
||||
BrandedPermission,
|
||||
ResourceName,
|
||||
ResourceType,
|
||||
} from './types';
|
||||
|
||||
export const PermissionSeparator = '||__||';
|
||||
export const ObjectSeparator = ':';
|
||||
|
||||
export function buildPermission<R extends AuthZRelation>(
|
||||
relation: R,
|
||||
object: AuthZObject<R>,
|
||||
): BrandedPermission {
|
||||
return `${relation}${PermissionSeparator}${object}` as BrandedPermission;
|
||||
}
|
||||
|
||||
export function buildObjectString(
|
||||
resource: AuthZResource,
|
||||
objectId: string,
|
||||
): `${AuthZResource}${typeof ObjectSeparator}${string}` {
|
||||
return `${resource}${ObjectSeparator}${objectId}` as const;
|
||||
}
|
||||
|
||||
export function parsePermission(
|
||||
permission: BrandedPermission,
|
||||
): {
|
||||
relation: AuthZRelation;
|
||||
object: string;
|
||||
} {
|
||||
const [relation, object] = permission.split(PermissionSeparator);
|
||||
return { relation: relation as AuthZRelation, object };
|
||||
}
|
||||
|
||||
const resourceNameToType = permissionsType.data.resources.reduce((acc, r) => {
|
||||
acc[r.name] = r.type;
|
||||
return acc;
|
||||
}, {} as Record<ResourceName, ResourceType>);
|
||||
|
||||
export function permissionToTransactionDto(
|
||||
permission: BrandedPermission,
|
||||
): AuthtypesTransactionDTO {
|
||||
const { relation, object: objectStr } = parsePermission(permission);
|
||||
const directType = resourceNameToType[objectStr as ResourceName];
|
||||
if (directType === 'metaresources') {
|
||||
return {
|
||||
relation,
|
||||
object: {
|
||||
resource: { name: objectStr, type: directType },
|
||||
selector: '*',
|
||||
},
|
||||
};
|
||||
}
|
||||
const [resourceName, selector] = objectStr.split(ObjectSeparator);
|
||||
const type =
|
||||
resourceNameToType[resourceName as ResourceName] ?? 'metaresource';
|
||||
|
||||
return {
|
||||
relation,
|
||||
object: {
|
||||
resource: { name: resourceName, type },
|
||||
selector: selector || '*',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function gettableTransactionToPermission(
|
||||
item: AuthtypesTransactionDTO,
|
||||
): BrandedPermission {
|
||||
const {
|
||||
relation,
|
||||
object: { resource, selector },
|
||||
} = item;
|
||||
const resourceName = String(resource.name);
|
||||
const selectorStr = typeof selector === 'string' ? selector : '*';
|
||||
const objectStr =
|
||||
resource.type === 'metaresources'
|
||||
? resourceName
|
||||
: `${resourceName}${ObjectSeparator}${selectorStr}`;
|
||||
return `${relation}${PermissionSeparator}${objectStr}` as BrandedPermission;
|
||||
}
|
||||
@@ -20,7 +20,6 @@ export const useHandleExplorerTabChange = (): {
|
||||
type: string,
|
||||
querySearchParameters?: ICurrentQueryData,
|
||||
redirectToUrl?: typeof ROUTES[keyof typeof ROUTES],
|
||||
newTab?: boolean,
|
||||
) => void;
|
||||
} => {
|
||||
const {
|
||||
@@ -64,7 +63,6 @@ export const useHandleExplorerTabChange = (): {
|
||||
type: string,
|
||||
currentQueryData?: ICurrentQueryData,
|
||||
redirectToUrl?: typeof ROUTES[keyof typeof ROUTES],
|
||||
newTab?: boolean,
|
||||
) => {
|
||||
const newPanelType = type as PANEL_TYPES;
|
||||
|
||||
@@ -83,21 +81,13 @@ export const useHandleExplorerTabChange = (): {
|
||||
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
|
||||
},
|
||||
redirectToUrl,
|
||||
undefined,
|
||||
newTab,
|
||||
);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
[QueryParams.viewName]: currentQueryData?.name || viewName,
|
||||
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
newTab,
|
||||
);
|
||||
redirectWithQueryBuilderData(query, {
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
[QueryParams.viewName]: currentQueryData?.name || viewName,
|
||||
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
|
||||
});
|
||||
}
|
||||
},
|
||||
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewName, viewKey],
|
||||
|
||||
@@ -54,7 +54,7 @@ export const stepIntervalUnchanged = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -177,7 +177,7 @@ export const replaceVariables = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -267,7 +267,7 @@ export const defaultOutput = {
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
timeAggregation: 'count',
|
||||
},
|
||||
],
|
||||
filter: { expression: '' },
|
||||
@@ -392,7 +392,7 @@ export const outputWithFunctions = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
@@ -429,7 +429,7 @@ export const outputWithFunctions = {
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
createSetCursorHandler,
|
||||
createSetLegendHandler,
|
||||
createSetSeriesHandler,
|
||||
getPlot,
|
||||
isScrollEventInPlot,
|
||||
updatePlotVisibility,
|
||||
updateWindowSize,
|
||||
@@ -54,7 +53,7 @@ export default function TooltipPlugin({
|
||||
const [viewState, setState] = useState<TooltipViewState>(
|
||||
createInitialViewState,
|
||||
);
|
||||
const { hasPlot, isHovering, isPinned, contents, style } = viewState;
|
||||
const { plot, isHovering, isPinned, contents, style } = viewState;
|
||||
|
||||
/**
|
||||
* Merge a partial view update into the current React state.
|
||||
@@ -73,25 +72,12 @@ export default function TooltipPlugin({
|
||||
layoutRef.current?.observer.disconnect();
|
||||
layoutRef.current = createLayoutObserver(layoutRef);
|
||||
|
||||
/**
|
||||
* Plot lifecycle and GC: viewState uses hasPlot (boolean), not the plot
|
||||
* reference; clearPlotReferences runs in cleanup so
|
||||
* detached canvases can be garbage collected.
|
||||
*/
|
||||
// Controller holds the mutable interaction state for this tooltip
|
||||
// instance. It is intentionally *not* React state so uPlot hooks
|
||||
// and DOM listeners can update it freely without triggering a
|
||||
// render on every mouse move.
|
||||
const controller: TooltipControllerState = createInitialControllerState();
|
||||
|
||||
/**
|
||||
* Clear plot references so detached canvases can be garbage collected.
|
||||
*/
|
||||
const clearPlotReferences = (): void => {
|
||||
controller.plot = null;
|
||||
updateState({ hasPlot: false });
|
||||
};
|
||||
|
||||
const syncTooltipWithDashboard = syncMode === DashboardCursorSync.Tooltip;
|
||||
|
||||
// Enable uPlot's built-in cursor sync when requested so that
|
||||
@@ -124,10 +110,9 @@ export default function TooltipPlugin({
|
||||
// Lock uPlot's internal cursor when the tooltip is pinned so
|
||||
// subsequent mouse moves do not move the crosshair.
|
||||
function updateCursorLock(): void {
|
||||
const plot = getPlot(controller);
|
||||
if (plot) {
|
||||
if (controller.plot) {
|
||||
// @ts-ignore uPlot cursor lock is not working as expected
|
||||
plot.cursor._lock = controller.pinned;
|
||||
controller.plot.cursor._lock = controller.pinned;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,9 +142,8 @@ export default function TooltipPlugin({
|
||||
const isPinnedBeforeDismiss = controller.pinned;
|
||||
controller.pinned = false;
|
||||
controller.hoverActive = false;
|
||||
const plot = getPlot(controller);
|
||||
if (plot) {
|
||||
plot.setCursor({ left: -10, top: -10 });
|
||||
if (controller.plot) {
|
||||
controller.plot.setCursor({ left: -10, top: -10 });
|
||||
}
|
||||
scheduleRender(isPinnedBeforeDismiss);
|
||||
}
|
||||
@@ -167,12 +151,11 @@ export default function TooltipPlugin({
|
||||
// Build the React node to be rendered inside the tooltip by
|
||||
// delegating to the caller-provided `render` function.
|
||||
function createTooltipContents(): React.ReactNode {
|
||||
const plot = getPlot(controller);
|
||||
if (!controller.hoverActive || !plot) {
|
||||
if (!controller.hoverActive || !controller.plot) {
|
||||
return null;
|
||||
}
|
||||
return renderRef.current({
|
||||
uPlotInstance: plot,
|
||||
uPlotInstance: controller.plot,
|
||||
dataIndexes: controller.seriesIndexes,
|
||||
seriesIndex: controller.focusedSeriesIndex,
|
||||
isPinned: controller.pinned,
|
||||
@@ -257,13 +240,9 @@ export default function TooltipPlugin({
|
||||
|
||||
// When pinning is enabled, a click on the plot overlay while
|
||||
// hovering converts the transient tooltip into a pinned one.
|
||||
// Uses getPlot(controller) to avoid closing over u (plot), which
|
||||
// would retain the plot and detached canvases across unmounts.
|
||||
const handleUPlotOverClick = (event: MouseEvent): void => {
|
||||
const plot = getPlot(controller);
|
||||
const handleUPlotOverClick = (u: uPlot, event: MouseEvent): void => {
|
||||
if (
|
||||
plot &&
|
||||
event.target === plot.over &&
|
||||
event.target === u.over &&
|
||||
controller.hoverActive &&
|
||||
!controller.pinned &&
|
||||
controller.focusedSeriesIndex != null
|
||||
@@ -281,9 +260,10 @@ export default function TooltipPlugin({
|
||||
// on the controller and optionally attach the pinning handler.
|
||||
const handleInit = (u: uPlot): void => {
|
||||
controller.plot = u;
|
||||
updateState({ hasPlot: true });
|
||||
updateState({ plot: u });
|
||||
if (canPinTooltip) {
|
||||
overClickHandler = handleUPlotOverClick;
|
||||
overClickHandler = (event: MouseEvent): void =>
|
||||
handleUPlotOverClick(u, event);
|
||||
u.over.addEventListener('click', overClickHandler);
|
||||
}
|
||||
};
|
||||
@@ -319,6 +299,7 @@ export default function TooltipPlugin({
|
||||
const handleSetCursor = createSetCursorHandler(ctx);
|
||||
|
||||
handleWindowResize();
|
||||
|
||||
const removeReadyHook = config.addHook('ready', (): void =>
|
||||
updatePlotVisibility(controller),
|
||||
);
|
||||
@@ -344,20 +325,16 @@ export default function TooltipPlugin({
|
||||
removeSetSeriesHook();
|
||||
removeSetLegendHook();
|
||||
removeSetCursorHook();
|
||||
if (overClickHandler) {
|
||||
const plot = getPlot(controller);
|
||||
if (plot) {
|
||||
plot.over.removeEventListener('click', overClickHandler);
|
||||
}
|
||||
if (controller.plot && overClickHandler) {
|
||||
controller.plot.over.removeEventListener('click', overClickHandler);
|
||||
overClickHandler = null;
|
||||
}
|
||||
clearPlotReferences();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
useLayoutEffect((): void => {
|
||||
if (!hasPlot || !layoutRef.current) {
|
||||
if (!plot || !layoutRef.current) {
|
||||
return;
|
||||
}
|
||||
const layout = layoutRef.current;
|
||||
@@ -372,9 +349,9 @@ export default function TooltipPlugin({
|
||||
layout.width = 0;
|
||||
layout.height = 0;
|
||||
}
|
||||
}, [isHovering, hasPlot]);
|
||||
}, [isHovering, plot]);
|
||||
|
||||
if (!hasPlot) {
|
||||
if (!plot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,6 @@ import {
|
||||
|
||||
const WINDOW_OFFSET = 16;
|
||||
|
||||
/** Get the plot instance from the controller; returns null if never set or cleared. */
|
||||
export function getPlot(controller: TooltipControllerState): uPlot | null {
|
||||
return controller.plot ?? null;
|
||||
}
|
||||
|
||||
export function createInitialControllerState(): TooltipControllerState {
|
||||
return {
|
||||
plot: null,
|
||||
@@ -51,13 +46,12 @@ export function updateWindowSize(controller: TooltipControllerState): void {
|
||||
* This is used to decide if a synced tooltip should be shown at all.
|
||||
*/
|
||||
export function updatePlotVisibility(controller: TooltipControllerState): void {
|
||||
const plot = getPlot(controller);
|
||||
if (!plot) {
|
||||
if (!controller.plot) {
|
||||
controller.plotWithinViewport = false;
|
||||
return;
|
||||
}
|
||||
controller.plotWithinViewport = isPlotInViewport(
|
||||
plot.rect,
|
||||
controller.plot.rect,
|
||||
controller.windowWidth,
|
||||
controller.windowHeight,
|
||||
);
|
||||
@@ -72,11 +66,10 @@ export function isScrollEventInPlot(
|
||||
event: Event,
|
||||
controller: TooltipControllerState,
|
||||
): boolean {
|
||||
const plot = getPlot(controller);
|
||||
return (
|
||||
event.target instanceof Node &&
|
||||
plot !== null &&
|
||||
event.target.contains(plot.root)
|
||||
controller.plot !== null &&
|
||||
event.target.contains(controller.plot.root)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,12 +165,11 @@ export function createSetLegendHandler(
|
||||
): (u: uPlot) => void {
|
||||
return (u: uPlot): void => {
|
||||
const { controller } = ctx;
|
||||
const plot = getPlot(controller);
|
||||
if (!plot?.cursor?.idxs) {
|
||||
if (!controller.plot?.cursor?.idxs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSeriesIndexes = plot.cursor.idxs.slice();
|
||||
const newSeriesIndexes = controller.plot.cursor.idxs.slice();
|
||||
const isAnySeriesActive = newSeriesIndexes.some((v, i) => i > 0 && v != null);
|
||||
|
||||
const previousCursorDrivenBySync = controller.cursorDrivenBySync;
|
||||
|
||||
@@ -18,8 +18,7 @@ export enum DashboardCursorSync {
|
||||
}
|
||||
|
||||
export interface TooltipViewState {
|
||||
/** Whether a plot instance exists; plot reference is in controller, not state. */
|
||||
hasPlot?: boolean;
|
||||
plot?: uPlot | null;
|
||||
style: Partial<CSSProperties>;
|
||||
isHovering: boolean;
|
||||
isPinned: boolean;
|
||||
|
||||
@@ -123,7 +123,7 @@ export function createInitialViewState(): TooltipViewState {
|
||||
isHovering: false,
|
||||
isPinned: false,
|
||||
contents: null,
|
||||
hasPlot: false,
|
||||
plot: null,
|
||||
dismiss: (): void => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -947,7 +947,6 @@ export function QueryBuilderProvider({
|
||||
searchParams?: Record<string, unknown>,
|
||||
redirectingUrl?: typeof ROUTES[keyof typeof ROUTES],
|
||||
shouldNotStringify?: boolean,
|
||||
newTab?: boolean,
|
||||
) => {
|
||||
const queryType =
|
||||
!query.queryType || !Object.values(EQueryType).includes(query.queryType)
|
||||
@@ -1014,7 +1013,7 @@ export function QueryBuilderProvider({
|
||||
? `${redirectingUrl}?${urlQuery}`
|
||||
: `${location.pathname}?${urlQuery}`;
|
||||
|
||||
safeNavigate(generatedUrl, { newTab });
|
||||
safeNavigate(generatedUrl);
|
||||
},
|
||||
[location.pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
@@ -72,6 +72,7 @@ export type UseQueryOperations = (
|
||||
handleChangeAggregatorAttribute: (
|
||||
value: BaseAutocompleteData,
|
||||
isEditMode?: boolean,
|
||||
attributeKeys?: BaseAutocompleteData[],
|
||||
) => void;
|
||||
handleChangeDataSource: (newSource: DataSource) => void;
|
||||
handleDeleteQuery: () => void;
|
||||
|
||||
@@ -278,7 +278,6 @@ export type QueryBuilderContextType = {
|
||||
searchParams?: Record<string, unknown>,
|
||||
redirectToUrl?: typeof ROUTES[keyof typeof ROUTES],
|
||||
shallStringify?: boolean,
|
||||
newTab?: boolean,
|
||||
) => void;
|
||||
handleRunQuery: () => void;
|
||||
resetQuery: (newCurrentQuery?: QueryState) => void;
|
||||
|
||||
@@ -6205,10 +6205,10 @@
|
||||
"@types/history" "^4.7.11"
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-syntax-highlighter@15.5.13":
|
||||
version "15.5.13"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz#c5baf62a3219b3bf28d39cfea55d0a49a263d1f2"
|
||||
integrity sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==
|
||||
"@types/react-syntax-highlighter@15.5.7":
|
||||
version "15.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.7.tgz#bd29020ccb118543d88779848f99059b64b02d0f"
|
||||
integrity sha512-bo5fEO5toQeyCp0zVHBeggclqf5SQ/Z5blfFmjwO5dkMVGPgmiwZsJh9nu/Bo5L7IHTuGWrja6LxJVE2uB5ZrQ==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -49,7 +48,6 @@ type provider struct {
|
||||
authzHandler authz.Handler
|
||||
zeusHandler zeus.Handler
|
||||
querierHandler querier.Handler
|
||||
serviceAccountHandler serviceaccount.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -71,7 +69,6 @@ func NewFactory(
|
||||
authzHandler authz.Handler,
|
||||
zeusHandler zeus.Handler,
|
||||
querierHandler querier.Handler,
|
||||
serviceAccountHandler serviceaccount.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
return newProvider(
|
||||
@@ -96,7 +93,6 @@ func NewFactory(
|
||||
authzHandler,
|
||||
zeusHandler,
|
||||
querierHandler,
|
||||
serviceAccountHandler,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -123,7 +119,6 @@ func newProvider(
|
||||
authzHandler authz.Handler,
|
||||
zeusHandler zeus.Handler,
|
||||
querierHandler querier.Handler,
|
||||
serviceAccountHandler serviceaccount.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -148,7 +143,6 @@ func newProvider(
|
||||
authzHandler: authzHandler,
|
||||
zeusHandler: zeusHandler,
|
||||
querierHandler: querierHandler,
|
||||
serviceAccountHandler: serviceAccountHandler,
|
||||
}
|
||||
|
||||
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
|
||||
@@ -229,10 +223,6 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addServiceAccountRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Create), handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account",
|
||||
Description: "This endpoint creates a service account",
|
||||
Request: new(serviceaccounttypes.PostableServiceAccount),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.List), handler.OpenAPIDef{
|
||||
ID: "ListServiceAccounts",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "List service accounts",
|
||||
Description: "This endpoint lists the service accounts for an organisation",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*serviceaccounttypes.ServiceAccount, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Get), handler.OpenAPIDef{
|
||||
ID: "GetServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Gets a service account",
|
||||
Description: "This endpoint gets an existing service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(serviceaccounttypes.ServiceAccount),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Update), handler.OpenAPIDef{
|
||||
ID: "UpdateServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Updates a service account",
|
||||
Description: "This endpoint updates an existing service account",
|
||||
Request: new(serviceaccounttypes.UpdatableServiceAccount),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/status", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.UpdateStatus), handler.OpenAPIDef{
|
||||
ID: "UpdateServiceAccountStatus",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Updates a service account status",
|
||||
Description: "This endpoint updates an existing service account status",
|
||||
Request: new(serviceaccounttypes.UpdatableServiceAccountStatus),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Delete), handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Deletes a service account",
|
||||
Description: "This endpoint deletes an existing service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.CreateFactorAPIKey), handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create a service account key",
|
||||
Description: "This endpoint creates a service account key",
|
||||
Request: new(serviceaccounttypes.PostableFactorAPIKey),
|
||||
RequestContentType: "",
|
||||
Response: new(serviceaccounttypes.GettableFactorAPIKeyWithKey),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.ListFactorAPIKey), handler.OpenAPIDef{
|
||||
ID: "ListServiceAccountKeys",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "List service account keys",
|
||||
Description: "This endpoint lists the service account keys",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*serviceaccounttypes.FactorAPIKey, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.UpdateFactorAPIKey), handler.OpenAPIDef{
|
||||
ID: "UpdateServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Updates a service account key",
|
||||
Description: "This endpoint updates an existing service account key",
|
||||
Request: new(serviceaccounttypes.UpdatableFactorAPIKey),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.RevokeFactorAPIKey), handler.OpenAPIDef{
|
||||
ID: "RevokeServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Revoke a service account key",
|
||||
Description: "This endpoint revokes an existing service account key",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -62,17 +62,14 @@ type AuthZ interface {
|
||||
// Lists all the roles for the organization filtered by name
|
||||
ListByOrgIDAndNames(context.Context, valuer.UUID, []string) ([]*roletypes.Role, error)
|
||||
|
||||
// Lists all the roles for the organization filtered by ids
|
||||
ListByOrgIDAndIDs(context.Context, valuer.UUID, []valuer.UUID) ([]*roletypes.Role, error)
|
||||
|
||||
// Grants a role to the subject based on role name.
|
||||
Grant(context.Context, valuer.UUID, []string, string) error
|
||||
Grant(context.Context, valuer.UUID, string, string) error
|
||||
|
||||
// Revokes a granted role from the subject based on role name.
|
||||
Revoke(context.Context, valuer.UUID, []string, string) error
|
||||
Revoke(context.Context, valuer.UUID, string, string) error
|
||||
|
||||
// Changes the granted role for the subject based on role name.
|
||||
ModifyGrant(context.Context, valuer.UUID, []string, []string, string) error
|
||||
ModifyGrant(context.Context, valuer.UUID, string, string, string) error
|
||||
|
||||
// Bootstrap the managed roles.
|
||||
CreateManagedRoles(context.Context, valuer.UUID, []*roletypes.Role) error
|
||||
|
||||
@@ -96,39 +96,6 @@ func (store *store) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(roles) != len(names) {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(
|
||||
nil,
|
||||
roletypes.ErrCodeRoleNotFound,
|
||||
"not all roles found for the provided names: %v", names,
|
||||
)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (store *store) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*roletypes.StorableRole, error) {
|
||||
roles := make([]*roletypes.StorableRole, 0)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&roles).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id IN (?)", bun.In(ids)).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(roles) != len(ids) {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(
|
||||
nil,
|
||||
roletypes.ErrCodeRoleNotFound,
|
||||
"not all roles found for the provided ids: %v", ids,
|
||||
)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -114,46 +114,28 @@ func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (provider *provider) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, ids []valuer.UUID) ([]*roletypes.Role, error) {
|
||||
storableRoles, err := provider.store.ListByOrgIDAndIDs(ctx, orgID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roles := make([]*roletypes.Role, len(storableRoles))
|
||||
for idx, storable := range storableRoles {
|
||||
roles[idx] = roletypes.NewRoleFromStorableRole(storable)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Grant(ctx context.Context, orgID valuer.UUID, names []string, subject string) error {
|
||||
selectors := make([]authtypes.Selector, len(names))
|
||||
for idx, name := range names {
|
||||
selectors[idx] = authtypes.MustNewSelector(authtypes.TypeRole, name)
|
||||
}
|
||||
|
||||
func (provider *provider) Grant(ctx context.Context, orgID valuer.UUID, name string, subject string) error {
|
||||
tuples, err := authtypes.TypeableRole.Tuples(
|
||||
subject,
|
||||
authtypes.RelationAssignee,
|
||||
selectors,
|
||||
[]authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, name),
|
||||
},
|
||||
orgID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return provider.Write(ctx, tuples, nil)
|
||||
}
|
||||
|
||||
func (provider *provider) ModifyGrant(ctx context.Context, orgID valuer.UUID, existingRoleNames []string, updatedRoleNames []string, subject string) error {
|
||||
err := provider.Revoke(ctx, orgID, existingRoleNames, subject)
|
||||
func (provider *provider) ModifyGrant(ctx context.Context, orgID valuer.UUID, existingRoleName string, updatedRoleName string, subject string) error {
|
||||
err := provider.Revoke(ctx, orgID, existingRoleName, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.Grant(ctx, orgID, updatedRoleNames, subject)
|
||||
err = provider.Grant(ctx, orgID, updatedRoleName, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -161,16 +143,13 @@ func (provider *provider) ModifyGrant(ctx context.Context, orgID valuer.UUID, ex
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, names []string, subject string) error {
|
||||
selectors := make([]authtypes.Selector, len(names))
|
||||
for idx, name := range names {
|
||||
selectors[idx] = authtypes.MustNewSelector(authtypes.TypeRole, name)
|
||||
}
|
||||
|
||||
func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, name string, subject string) error {
|
||||
tuples, err := authtypes.TypeableRole.Tuples(
|
||||
subject,
|
||||
authtypes.RelationAssignee,
|
||||
selectors,
|
||||
[]authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, name),
|
||||
},
|
||||
orgID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -199,7 +178,7 @@ func (provider *provider) CreateManagedRoles(ctx context.Context, _ valuer.UUID,
|
||||
}
|
||||
|
||||
func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
|
||||
return provider.Grant(ctx, orgID, []string{roletypes.SigNozAdminRoleName}, authtypes.MustNewSubject(authtypes.TypeableUser, userID.String(), orgID, nil))
|
||||
return provider.Grant(ctx, orgID, roletypes.SigNozAdminRoleName, authtypes.MustNewSubject(authtypes.TypeableUser, userID.String(), orgID, nil))
|
||||
}
|
||||
|
||||
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *roletypes.Role) error {
|
||||
|
||||
44
pkg/http/middleware/recovery.go
Normal file
44
pkg/http/middleware/recovery.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
)
|
||||
|
||||
// Recovery is a middleware that recovers from panics, logs the panic,
|
||||
// and returns a 500 Internal Server Error.
|
||||
type Recovery struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewRecovery creates a new Recovery middleware.
|
||||
func NewRecovery(logger *slog.Logger) Wrapper {
|
||||
return &Recovery{
|
||||
logger: logger.With("pkg", "http-middleware-recovery"),
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap is the middleware handler.
|
||||
func (m *Recovery) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
m.logger.ErrorContext(
|
||||
r.Context(),
|
||||
"panic recovered",
|
||||
"err", err, "stack", string(debug.Stack()),
|
||||
)
|
||||
|
||||
render.Error(w, errors.NewInternalf(
|
||||
errors.CodeInternal, "internal server error",
|
||||
))
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
@@ -57,81 +56,11 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(srikanthccv): use metadata store to fetch metric metadata
|
||||
func (m *module) ListMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if params.Source == "meter" {
|
||||
return m.listMeterMetrics(ctx, params)
|
||||
}
|
||||
return m.listMetrics(ctx, orgID, params)
|
||||
}
|
||||
|
||||
func (m *module) listMeterMetrics(ctx context.Context, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"metric_name",
|
||||
"any(description) AS description",
|
||||
"any(type) AS metric_type",
|
||||
"any(unit) AS metric_unit",
|
||||
"argMax(temporality, unix_milli) AS temporality",
|
||||
"any(is_monotonic) AS is_monotonic",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymeter.DBName, telemetrymeter.SamplesTableName))
|
||||
|
||||
if params.Start != nil && params.End != nil {
|
||||
sb.Where(sb.Between("unix_milli", *params.Start, *params.End))
|
||||
}
|
||||
|
||||
if params.Search != "" {
|
||||
searchLower := strings.ToLower(params.Search)
|
||||
searchLower = strings.ReplaceAll(searchLower, "%", "\\%")
|
||||
searchLower = strings.ReplaceAll(searchLower, "_", "\\_")
|
||||
sb.Where(sb.Like("lower(metric_name)", fmt.Sprintf("%%%s%%", searchLower)))
|
||||
}
|
||||
|
||||
sb.GroupBy("metric_name")
|
||||
sb.OrderBy("metric_name ASC")
|
||||
sb.Limit(params.Limit)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
|
||||
db := m.telemetryStore.ClickhouseDB()
|
||||
rows, err := db.Query(valueCtx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to list meter metrics")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
metrics := make([]metricsexplorertypes.ListMetric, 0)
|
||||
for rows.Next() {
|
||||
var metric metricsexplorertypes.ListMetric
|
||||
if err := rows.Scan(
|
||||
&metric.MetricName,
|
||||
&metric.Description,
|
||||
&metric.MetricType,
|
||||
&metric.MetricUnit,
|
||||
&metric.Temporality,
|
||||
&metric.IsMonotonic,
|
||||
); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan meter metric")
|
||||
}
|
||||
metrics = append(metrics, metric)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error iterating meter metrics")
|
||||
}
|
||||
|
||||
return &metricsexplorertypes.ListMetricsResponse{
|
||||
Metrics: metrics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *module) listMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("DISTINCT metric_name")
|
||||
|
||||
@@ -786,8 +715,9 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
|
||||
Logger: m.logger,
|
||||
FieldMapper: m.fieldMapper,
|
||||
ConditionBuilder: m.condBuilder,
|
||||
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
|
||||
FieldKeys: keys,
|
||||
FullTextColumn: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "labels"},
|
||||
FieldKeys: keys,
|
||||
}
|
||||
|
||||
startNs := querybuilder.ToNanoSecs(uint64(startMillis))
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -25,7 +24,7 @@ func NewModule(sqlstore sqlstore.SQLStore) savedview.Module {
|
||||
}
|
||||
|
||||
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, name string, category string) ([]*v3.SavedView, error) {
|
||||
var views []savedviewtypes.SavedView
|
||||
var views []types.SavedView
|
||||
var err error
|
||||
if len(category) == 0 {
|
||||
err = module.sqlstore.BunDB().NewSelect().Model(&views).Where("org_id = ? AND source_page = ? AND name LIKE ?", orgID, sourcePage, "%"+name+"%").Scan(ctx)
|
||||
@@ -77,7 +76,7 @@ func (module *module) CreateView(ctx context.Context, orgID string, view v3.Save
|
||||
createBy := claims.Email
|
||||
updatedBy := claims.Email
|
||||
|
||||
dbView := savedviewtypes.SavedView{
|
||||
dbView := types.SavedView{
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
@@ -106,7 +105,7 @@ func (module *module) CreateView(ctx context.Context, orgID string, view v3.Save
|
||||
}
|
||||
|
||||
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error) {
|
||||
var view savedviewtypes.SavedView
|
||||
var view types.SavedView
|
||||
err := module.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, uuid.StringValue()).Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved view")
|
||||
@@ -147,7 +146,7 @@ func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.
|
||||
updatedBy := claims.Email
|
||||
|
||||
_, err = module.sqlstore.BunDB().NewUpdate().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Model(&types.SavedView{}).
|
||||
Set("updated_at = ?, updated_by = ?, name = ?, category = ?, source_page = ?, tags = ?, data = ?, extra_data = ?",
|
||||
updatedAt, updatedBy, view.Name, view.Category, view.SourcePage, strings.Join(view.Tags, ","), data, view.ExtraData).
|
||||
Where("id = ?", uuid.StringValue()).
|
||||
@@ -161,7 +160,7 @@ func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.
|
||||
|
||||
func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
|
||||
_, err := module.sqlstore.BunDB().NewDelete().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Model(&types.SavedView{}).
|
||||
Where("id = ?", uuid.StringValue()).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
@@ -172,7 +171,7 @@ func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.
|
||||
}
|
||||
|
||||
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
savedViews := []*savedviewtypes.SavedView{}
|
||||
savedViews := []*types.SavedView{}
|
||||
|
||||
err := module.
|
||||
sqlstore.
|
||||
@@ -185,5 +184,5 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return savedviewtypes.NewStatsFromSavedViews(savedViews), nil
|
||||
return types.NewStatsFromSavedViews(savedViews), nil
|
||||
}
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
package implserviceaccount
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module serviceaccount.Module
|
||||
}
|
||||
|
||||
func NewHandler(module serviceaccount.Module) serviceaccount.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(serviceaccounttypes.PostableServiceAccount)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount := serviceaccounttypes.NewServiceAccount(req.Name, req.Email, req.Roles, serviceaccounttypes.StatusActive, valuer.MustNewUUID(claims.OrgID))
|
||||
err = handler.module.Create(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, types.Identifiable{ID: serviceAccount.ID})
|
||||
}
|
||||
|
||||
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, serviceAccount)
|
||||
}
|
||||
|
||||
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccounts, err := handler.module.List(ctx, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, serviceAccounts)
|
||||
}
|
||||
|
||||
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(serviceaccounttypes.UpdatableServiceAccount)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount.Update(req.Name, req.Email, req.Roles)
|
||||
err = handler.module.Update(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateStatus(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(serviceaccounttypes.UpdatableServiceAccountStatus)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount.UpdateStatus(req.Status)
|
||||
err = handler.module.UpdateStatus(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.Delete(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateFactorAPIKey(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(serviceaccounttypes.PostableFactorAPIKey)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// this takes care of checking the existence of service account and the org constraint.
|
||||
serviceAccount, err := handler.module.GetWithoutRoles(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
factorAPIKey, err := serviceAccount.NewFactorAPIKey(req.Name, req.ExpiresAt)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.CreateFactorAPIKey(ctx, factorAPIKey)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, serviceaccounttypes.NewGettableFactorAPIKeyWithKey(factorAPIKey.ID, factorAPIKey.Key))
|
||||
}
|
||||
|
||||
func (handler *handler) ListFactorAPIKey(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount, err := handler.module.GetWithoutRoles(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
factorAPIKeys, err := handler.module.ListFactorAPIKey(ctx, serviceAccount.ID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, serviceaccounttypes.NewGettableFactorAPIKeys(factorAPIKeys))
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateFactorAPIKey(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
factorAPIKeyID, err := valuer.NewUUID(mux.Vars(r)["fid"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(serviceaccounttypes.UpdatableFactorAPIKey)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount, err := handler.module.GetWithoutRoles(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
factorAPIKey, err := handler.module.GetFactorAPIKey(ctx, serviceAccount.ID, factorAPIKeyID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
factorAPIKey.Update(req.Name, req.ExpiresAt)
|
||||
err = handler.module.UpdateFactorAPIKey(ctx, serviceAccount.ID, factorAPIKey)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) RevokeFactorAPIKey(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
factorAPIKeyID, err := valuer.NewUUID(mux.Vars(r)["fid"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount, err := handler.module.GetWithoutRoles(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.RevokeFactorAPIKey(ctx, serviceAccount.ID, factorAPIKeyID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
package implserviceaccount
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/emailing"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/emailtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store serviceaccounttypes.Store
|
||||
authz authz.AuthZ
|
||||
emailing emailing.Emailing
|
||||
settings factory.ScopedProviderSettings
|
||||
}
|
||||
|
||||
func NewModule(store serviceaccounttypes.Store, authz authz.AuthZ, emailing emailing.Emailing, providerSettings factory.ProviderSettings) serviceaccount.Module {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount")
|
||||
return &module{store: store, authz: authz, emailing: emailing, settings: settings}
|
||||
}
|
||||
|
||||
func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAccount *serviceaccounttypes.ServiceAccount) error {
|
||||
// validates the presence of all roles passed in the create request
|
||||
roles, err := module.authz.ListByOrgIDAndNames(ctx, orgID, serviceAccount.Roles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// authz actions cannot run in sql transactions
|
||||
err = module.authz.Grant(ctx, orgID, serviceAccount.Roles, authtypes.MustNewSubject(authtypes.TypeableUser, serviceAccount.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storableServiceAccount := serviceaccounttypes.NewStorableServiceAccount(serviceAccount)
|
||||
storableServiceAccountRoles := serviceaccounttypes.NewStorableServiceAccountRoles(serviceAccount.ID, roles)
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err := module.store.Create(ctx, storableServiceAccount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.CreateServiceAccountRoles(ctx, storableServiceAccountRoles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
storableServiceAccount, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// did the orchestration on application layer instead of DB as the ORM also does it anyways for many to many tables.
|
||||
storableServiceAccountRoles, err := module.store.GetServiceAccountRoles(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleIDs := make([]valuer.UUID, len(storableServiceAccountRoles))
|
||||
for idx, sar := range storableServiceAccountRoles {
|
||||
roleIDs[idx] = valuer.MustNewUUID(sar.RoleID)
|
||||
}
|
||||
|
||||
roles, err := module.authz.ListByOrgIDAndIDs(ctx, orgID, roleIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rolesNames, err := serviceaccounttypes.NewRolesFromStorableServiceAccountRoles(storableServiceAccountRoles, roles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serviceAccount := serviceaccounttypes.NewServiceAccountFromStorables(storableServiceAccount, rolesNames)
|
||||
return serviceAccount, nil
|
||||
}
|
||||
|
||||
func (module *module) GetWithoutRoles(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
storableServiceAccount, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// passing []string{} (not nil to prevent panics) roles as the function isn't supposed to put roles.
|
||||
serviceAccount := serviceaccounttypes.NewServiceAccountFromStorables(storableServiceAccount, []string{})
|
||||
return serviceAccount, nil
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.ServiceAccount, error) {
|
||||
storableServiceAccounts, err := module.store.List(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storableServiceAccountRoles, err := module.store.ListServiceAccountRolesByOrgID(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// convert the service account roles to structured data
|
||||
saIDToRoleIDs, roleIDs := serviceaccounttypes.GetUniqueRolesAndServiceAccountMapping(storableServiceAccountRoles)
|
||||
roles, err := module.authz.ListByOrgIDAndIDs(ctx, orgID, roleIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// fill in the role fetched data back to service account
|
||||
serviceAccounts := serviceaccounttypes.NewServiceAccountsFromRoles(storableServiceAccounts, roles, saIDToRoleIDs)
|
||||
return serviceAccounts, nil
|
||||
}
|
||||
|
||||
func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
|
||||
serviceAccount, err := module.Get(ctx, orgID, input.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
roles, err := module.authz.ListByOrgIDAndNames(ctx, orgID, input.Roles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// gets the role diff if any to modify grants.
|
||||
grants, revokes := serviceAccount.PatchRoles(input)
|
||||
err = module.authz.ModifyGrant(ctx, orgID, revokes, grants, authtypes.MustNewSubject(authtypes.TypeableUser, serviceAccount.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storableServiceAccountRoles := serviceaccounttypes.NewStorableServiceAccountRoles(serviceAccount.ID, roles)
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err := module.store.Update(ctx, orgID, serviceaccounttypes.NewStorableServiceAccount(input))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete all the service account roles and create new rather than diff here.
|
||||
err = module.store.DeleteServiceAccountRoles(ctx, input.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.CreateServiceAccountRoles(ctx, storableServiceAccountRoles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateStatus(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
|
||||
serviceAccount, err := module.Get(ctx, orgID, input.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if input.Status == serviceAccount.Status {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch input.Status {
|
||||
case serviceaccounttypes.StatusActive:
|
||||
err := module.activateServiceAccount(ctx, orgID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case serviceaccounttypes.StatusDisabled:
|
||||
err := module.disableServiceAccount(ctx, orgID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
serviceAccount, err := module.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// revoke from authz first as this cannot run in sql transaction
|
||||
err = module.authz.Revoke(ctx, orgID, serviceAccount.Roles, authtypes.MustNewSubject(authtypes.TypeableUser, serviceAccount.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err := module.store.DeleteServiceAccountRoles(ctx, serviceAccount.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.RevokeAllFactorAPIKeys(ctx, serviceAccount.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.Delete(ctx, serviceAccount.OrgID, serviceAccount.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) CreateFactorAPIKey(ctx context.Context, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
|
||||
storableFactorAPIKey := serviceaccounttypes.NewStorableFactorAPIKey(factorAPIKey)
|
||||
|
||||
err := module.store.CreateFactorAPIKey(ctx, storableFactorAPIKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serviceAccount, err := module.store.GetByID(ctx, factorAPIKey.ServiceAccountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := module.emailing.SendHTML(ctx, serviceAccount.Email, "New API Key created for your SigNoz account", emailtypes.TemplateNameAPIKeyEvent, map[string]any{
|
||||
"Name": serviceAccount.Name,
|
||||
"KeyName": factorAPIKey.Name,
|
||||
"KeyID": factorAPIKey.ID.String(),
|
||||
"KeyCreatedAt": factorAPIKey.CreatedAt.String(),
|
||||
}); err != nil {
|
||||
module.settings.Logger().ErrorContext(ctx, "failed to send email", "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.FactorAPIKey, error) {
|
||||
storableFactorAPIKey, err := module.store.GetFactorAPIKey(ctx, serviceAccountID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return serviceaccounttypes.NewFactorAPIKeyFromStorable(storableFactorAPIKey), nil
|
||||
}
|
||||
|
||||
func (module *module) ListFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID) ([]*serviceaccounttypes.FactorAPIKey, error) {
|
||||
storables, err := module.store.ListFactorAPIKey(ctx, serviceAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return serviceaccounttypes.NewFactorAPIKeyFromStorables(storables), nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
|
||||
return module.store.UpdateFactorAPIKey(ctx, serviceAccountID, serviceaccounttypes.NewStorableFactorAPIKey(factorAPIKey))
|
||||
}
|
||||
|
||||
func (module *module) RevokeFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) error {
|
||||
factorAPIKey, err := module.GetFactorAPIKey(ctx, serviceAccountID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.RevokeFactorAPIKey(ctx, serviceAccountID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serviceAccount, err := module.store.GetByID(ctx, serviceAccountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := module.emailing.SendHTML(ctx, serviceAccount.Email, "API Key revoked for your SigNoz account", emailtypes.TemplateNameAPIKeyEvent, map[string]any{
|
||||
"Name": serviceAccount.Name,
|
||||
"KeyName": factorAPIKey.Name,
|
||||
"KeyID": factorAPIKey.ID.String(),
|
||||
"KeyCreatedAt": factorAPIKey.CreatedAt.String(),
|
||||
}); err != nil {
|
||||
module.settings.Logger().ErrorContext(ctx, "failed to send email", "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) disableServiceAccount(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
|
||||
err := module.authz.Revoke(ctx, orgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableUser, input.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
// revoke all the API keys on disable
|
||||
err := module.store.RevokeAllFactorAPIKeys(ctx, input.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update the status but do not delete the role mappings as we will reuse them on activation.
|
||||
err = module.Update(ctx, orgID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) activateServiceAccount(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
|
||||
err := module.authz.Grant(ctx, orgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableUser, input.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.Update(ctx, orgID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
package implserviceaccount
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) serviceaccounttypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, storable *serviceaccounttypes.StorableServiceAccount) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(storable).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeServiceAccountAlreadyExists, "service account with id: %s already exists", storable.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.StorableServiceAccount, error) {
|
||||
storable := new(serviceaccounttypes.StorableServiceAccount)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccountNotFound, "service account with id: %s doesn't exist in org: %s", id, orgID)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetByID(ctx context.Context, id valuer.UUID) (*serviceaccounttypes.StorableServiceAccount, error) {
|
||||
storable := new(serviceaccounttypes.StorableServiceAccount)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccountNotFound, "service account with id: %s doesn't exist", id)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.StorableServiceAccount, error) {
|
||||
storables := make([]*serviceaccounttypes.StorableServiceAccount, 0)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&storables).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storables, nil
|
||||
}
|
||||
|
||||
func (store *store) Update(ctx context.Context, orgID valuer.UUID, storable *serviceaccounttypes.StorableServiceAccount) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(storable).
|
||||
WherePK().
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(serviceaccounttypes.StorableServiceAccount)).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) CreateServiceAccountRoles(ctx context.Context, storables []*serviceaccounttypes.StorableServiceAccountRole) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(&storables).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeServiceAccountRoleAlreadyExists, "duplicate role assignments for service account")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetServiceAccountRoles(ctx context.Context, id valuer.UUID) ([]*serviceaccounttypes.StorableServiceAccountRole, error) {
|
||||
storables := make([]*serviceaccounttypes.StorableServiceAccountRole, 0)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&storables).
|
||||
Where("service_account_id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
// no need to wrap not found here as this is many to many table
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storables, nil
|
||||
}
|
||||
|
||||
func (store *store) ListServiceAccountRolesByOrgID(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.StorableServiceAccountRole, error) {
|
||||
storables := make([]*serviceaccounttypes.StorableServiceAccountRole, 0)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&storables).
|
||||
Join("JOIN service_account").
|
||||
JoinOn("service_account.id = service_account_role.service_account_id").
|
||||
Where("service_account.org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storables, nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteServiceAccountRoles(ctx context.Context, id valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(serviceaccounttypes.StorableServiceAccountRole)).
|
||||
Where("service_account_id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) CreateFactorAPIKey(ctx context.Context, storable *serviceaccounttypes.StorableFactorAPIKey) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(storable).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeServiceAccountFactorAPIKeyAlreadyExists, "api key with name: %s already exists for service account: %s", storable.Name, storable.ServiceAccountID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.StorableFactorAPIKey, error) {
|
||||
storable := new(serviceaccounttypes.StorableFactorAPIKey)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("id = ?", id).
|
||||
Where("service_account_id = ?", serviceAccountID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccounFactorAPIKeytNotFound, "api key with id: %s doesn't exist for service account: %s", id, serviceAccountID)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) ListFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID) ([]*serviceaccounttypes.StorableFactorAPIKey, error) {
|
||||
storables := make([]*serviceaccounttypes.StorableFactorAPIKey, 0)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&storables).
|
||||
Where("service_account_id = ?", serviceAccountID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storables, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, storable *serviceaccounttypes.StorableFactorAPIKey) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(storable).
|
||||
Where("service_account_id = ?", serviceAccountID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) RevokeFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(serviceaccounttypes.StorableFactorAPIKey)).
|
||||
Where("service_account_id = ?", serviceAccountID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) RevokeAllFactorAPIKeys(ctx context.Context, serviceAccountID valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(serviceaccounttypes.StorableFactorAPIKey)).
|
||||
Where("service_account_id = ?", serviceAccountID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) RunInTx(ctx context.Context, cb func(context.Context) error) error {
|
||||
return store.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||
return cb(ctx)
|
||||
})
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package serviceaccount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
// Creates a new service account for an organization.
|
||||
Create(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) error
|
||||
|
||||
// Gets a service account by id.
|
||||
Get(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccount, error)
|
||||
|
||||
// Gets a service account by id without fetching roles.
|
||||
GetWithoutRoles(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccount, error)
|
||||
|
||||
// List all service accounts for an organization.
|
||||
List(context.Context, valuer.UUID) ([]*serviceaccounttypes.ServiceAccount, error)
|
||||
|
||||
// Updates an existing service account
|
||||
Update(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) error
|
||||
|
||||
// Updates an existing service account status
|
||||
UpdateStatus(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) error
|
||||
|
||||
// Deletes an existing service account by id
|
||||
Delete(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
// Creates a new API key for a service account
|
||||
CreateFactorAPIKey(context.Context, *serviceaccounttypes.FactorAPIKey) error
|
||||
|
||||
// Gets a factor API key by id
|
||||
GetFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.FactorAPIKey, error)
|
||||
|
||||
// Lists all the API keys for a service account
|
||||
ListFactorAPIKey(context.Context, valuer.UUID) ([]*serviceaccounttypes.FactorAPIKey, error)
|
||||
|
||||
// Updates an existing API key for a service account
|
||||
UpdateFactorAPIKey(context.Context, valuer.UUID, *serviceaccounttypes.FactorAPIKey) error
|
||||
|
||||
// Revokes an existing API key for a service account
|
||||
RevokeFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) error
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
Create(http.ResponseWriter, *http.Request)
|
||||
|
||||
Get(http.ResponseWriter, *http.Request)
|
||||
|
||||
List(http.ResponseWriter, *http.Request)
|
||||
|
||||
Update(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateStatus(http.ResponseWriter, *http.Request)
|
||||
|
||||
Delete(http.ResponseWriter, *http.Request)
|
||||
|
||||
CreateFactorAPIKey(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListFactorAPIKey(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateFactorAPIKey(http.ResponseWriter, *http.Request)
|
||||
|
||||
RevokeFactorAPIKey(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user