mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-23 12:20:31 +01:00
Compare commits
24 Commits
test/resou
...
issue_4361
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
910cbf55cf | ||
|
|
c30b3870f2 | ||
|
|
93f5df9185 | ||
|
|
89b755a6b0 | ||
|
|
021f1c5775 | ||
|
|
30d3f754b5 | ||
|
|
d5dcdf382c | ||
|
|
ce5e3e7943 | ||
|
|
a4da71eb52 | ||
|
|
f5ed4c90b0 | ||
|
|
c03e97221a | ||
|
|
cd224616f0 | ||
|
|
50b42aca8c | ||
|
|
dbad6cff96 | ||
|
|
73a44cee77 | ||
|
|
b9b7c10af9 | ||
|
|
3d71707f0b | ||
|
|
f59f611ea2 | ||
|
|
07131f10a9 | ||
|
|
d930e77870 | ||
|
|
8a9f6bee98 | ||
|
|
4b06b4546f | ||
|
|
526b08a179 | ||
|
|
c7ab610bd8 |
70
.github/workflows/e2eci.yaml
vendored
Normal file
70
.github/workflows/e2eci.yaml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: e2eci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- labeled
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
project:
|
||||
- chromium
|
||||
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'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
- name: uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: python-install
|
||||
run: |
|
||||
cd tests && uv sync
|
||||
- name: yarn-install
|
||||
run: |
|
||||
cd tests/e2e && yarn install --frozen-lockfile
|
||||
- name: playwright-browsers
|
||||
run: |
|
||||
cd tests/e2e && yarn playwright install --with-deps ${{ matrix.project }}
|
||||
- name: bring-up-stack
|
||||
run: |
|
||||
cd tests && \
|
||||
uv run pytest \
|
||||
--basetemp=./tmp/ \
|
||||
-vv --reuse --with-web \
|
||||
e2e/bootstrap/setup.py::test_setup
|
||||
- name: playwright-test
|
||||
run: |
|
||||
cd tests/e2e && \
|
||||
yarn playwright test --project=${{ matrix.project }}
|
||||
- name: teardown-stack
|
||||
if: always()
|
||||
run: |
|
||||
cd tests && \
|
||||
uv run pytest \
|
||||
--basetemp=./tmp/ \
|
||||
-vv --teardown \
|
||||
e2e/bootstrap/setup.py::test_teardown
|
||||
- name: upload-artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-artifacts-${{ matrix.project }}
|
||||
path: tests/e2e/artifacts/
|
||||
retention-days: 5
|
||||
23
.github/workflows/integrationci.yaml
vendored
23
.github/workflows/integrationci.yaml
vendored
@@ -25,11 +25,11 @@ jobs:
|
||||
uses: astral-sh/setup-uv@v4
|
||||
- name: install
|
||||
run: |
|
||||
cd tests/integration && uv sync
|
||||
cd tests && uv sync
|
||||
- name: fmt
|
||||
run: |
|
||||
make py-fmt
|
||||
git diff --exit-code -- tests/integration/
|
||||
git diff --exit-code -- tests/
|
||||
- name: lint
|
||||
run: |
|
||||
make py-lint
|
||||
@@ -37,21 +37,21 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
src:
|
||||
- bootstrap
|
||||
- passwordauthn
|
||||
suite:
|
||||
- alerts
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
- dashboard
|
||||
- ingestionkeys
|
||||
- logspipelines
|
||||
- passwordauthn
|
||||
- preference
|
||||
- querier
|
||||
- rawexportdata
|
||||
- role
|
||||
- ttl
|
||||
- alerts
|
||||
- ingestionkeys
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
- ttl
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
@@ -79,8 +79,9 @@ jobs:
|
||||
uses: astral-sh/setup-uv@v4
|
||||
- name: install
|
||||
run: |
|
||||
cd tests/integration && uv sync
|
||||
cd tests && uv sync
|
||||
- name: webdriver
|
||||
if: matrix.suite == 'callbackauthn'
|
||||
run: |
|
||||
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
|
||||
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list
|
||||
@@ -99,10 +100,10 @@ jobs:
|
||||
google-chrome-stable --version
|
||||
- name: run
|
||||
run: |
|
||||
cd tests/integration && \
|
||||
cd tests && \
|
||||
uv run pytest \
|
||||
--basetemp=./tmp/ \
|
||||
src/${{matrix.src}} \
|
||||
integration/tests/${{matrix.suite}} \
|
||||
--sqlstore-provider ${{matrix.sqlstore-provider}} \
|
||||
--sqlite-mode ${{matrix.sqlite-mode}} \
|
||||
--postgres-version ${{matrix.postgres-version}} \
|
||||
|
||||
62
.github/workflows/run-e2e.yaml
vendored
62
.github/workflows/run-e2e.yaml
vendored
@@ -1,62 +0,0 @@
|
||||
name: e2eci
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
userRole:
|
||||
description: "Role of the user (ADMIN, EDITOR, VIEWER)"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- ADMIN
|
||||
- EDITOR
|
||||
- VIEWER
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Playwright Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Mask secrets and input
|
||||
run: |
|
||||
echo "::add-mask::${{ secrets.BASE_URL }}"
|
||||
echo "::add-mask::${{ secrets.LOGIN_USERNAME }}"
|
||||
echo "::add-mask::${{ secrets.LOGIN_PASSWORD }}"
|
||||
echo "::add-mask::${{ github.event.inputs.userRole }}"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm install -g yarn
|
||||
yarn
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
working-directory: frontend
|
||||
run: yarn playwright install --with-deps
|
||||
|
||||
- name: Run Playwright Tests
|
||||
working-directory: frontend
|
||||
run: |
|
||||
BASE_URL="${{ secrets.BASE_URL }}" \
|
||||
LOGIN_USERNAME="${{ secrets.LOGIN_USERNAME }}" \
|
||||
LOGIN_PASSWORD="${{ secrets.LOGIN_PASSWORD }}" \
|
||||
USER_ROLE="${{ github.event.inputs.userRole }}" \
|
||||
yarn playwright test
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 30
|
||||
22
Makefile
22
Makefile
@@ -201,26 +201,26 @@ docker-buildx-enterprise: go-build-enterprise js-build
|
||||
# python commands
|
||||
##############################################################
|
||||
.PHONY: py-fmt
|
||||
py-fmt: ## Run black for integration tests
|
||||
@cd tests/integration && uv run black .
|
||||
py-fmt: ## Run black across the shared tests project
|
||||
@cd tests && uv run black .
|
||||
|
||||
.PHONY: py-lint
|
||||
py-lint: ## Run lint for integration tests
|
||||
@cd tests/integration && uv run isort .
|
||||
@cd tests/integration && uv run autoflake .
|
||||
@cd tests/integration && uv run pylint .
|
||||
py-lint: ## Run lint across the shared tests project
|
||||
@cd tests && uv run isort .
|
||||
@cd tests && uv run autoflake .
|
||||
@cd tests && uv run pylint .
|
||||
|
||||
.PHONY: py-test-setup
|
||||
py-test-setup: ## Runs integration tests
|
||||
@cd tests/integration && uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no src/bootstrap/setup.py::test_setup
|
||||
py-test-setup: ## Bring up the shared SigNoz backend used by integration and e2e tests
|
||||
@cd tests && uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/bootstrap/setup.py::test_setup
|
||||
|
||||
.PHONY: py-test-teardown
|
||||
py-test-teardown: ## Runs integration tests with teardown
|
||||
@cd tests/integration && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no src/bootstrap/setup.py::test_teardown
|
||||
py-test-teardown: ## Tear down the shared SigNoz backend
|
||||
@cd tests && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no integration/bootstrap/setup.py::test_teardown
|
||||
|
||||
.PHONY: py-test
|
||||
py-test: ## Runs integration tests
|
||||
@cd tests/integration && uv run pytest --basetemp=./tmp/ -vv --capture=no src/
|
||||
@cd tests && uv run pytest --basetemp=./tmp/ -vv --capture=no integration/tests/
|
||||
|
||||
.PHONY: py-clean
|
||||
py-clean: ## Clear all pycache and pytest cache from tests directory recursively
|
||||
|
||||
@@ -2287,6 +2287,125 @@ components:
|
||||
enabled:
|
||||
type: boolean
|
||||
type: object
|
||||
InframonitoringtypesHostFilter:
|
||||
properties:
|
||||
expression:
|
||||
type: string
|
||||
filterByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostStatus'
|
||||
type: object
|
||||
InframonitoringtypesHostRecord:
|
||||
properties:
|
||||
activeHostCount:
|
||||
type: integer
|
||||
cpu:
|
||||
format: double
|
||||
type: number
|
||||
diskUsage:
|
||||
format: double
|
||||
type: number
|
||||
hostName:
|
||||
type: string
|
||||
inactiveHostCount:
|
||||
type: integer
|
||||
load15:
|
||||
format: double
|
||||
type: number
|
||||
memory:
|
||||
format: double
|
||||
type: number
|
||||
meta:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
status:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostStatus'
|
||||
wait:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- hostName
|
||||
- status
|
||||
- activeHostCount
|
||||
- inactiveHostCount
|
||||
- cpu
|
||||
- memory
|
||||
- wait
|
||||
- load15
|
||||
- diskUsage
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesHostStatus:
|
||||
enum:
|
||||
- active
|
||||
- inactive
|
||||
- ""
|
||||
type: string
|
||||
InframonitoringtypesHosts:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesResponseType'
|
||||
warning:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
|
||||
required:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesPostableHosts:
|
||||
properties:
|
||||
end:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostFilter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
orderBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
start:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesRequiredMetricsCheck:
|
||||
properties:
|
||||
missingMetrics:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- missingMetrics
|
||||
type: object
|
||||
InframonitoringtypesResponseType:
|
||||
enum:
|
||||
- list
|
||||
- grouped_list
|
||||
type: string
|
||||
MetricsexplorertypesInspectMetricsRequest:
|
||||
properties:
|
||||
end:
|
||||
@@ -4268,6 +4387,193 @@ components:
|
||||
type: object
|
||||
Sigv4SigV4Config:
|
||||
type: object
|
||||
SpanattributemappingtypesCondition:
|
||||
properties:
|
||||
attributes:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
resource:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- attributes
|
||||
- resource
|
||||
type: object
|
||||
SpanattributemappingtypesFieldContext:
|
||||
enum:
|
||||
- attribute
|
||||
- resource
|
||||
type: string
|
||||
SpanattributemappingtypesGettableGroups:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesGroup'
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpanattributemappingtypesGettableMappers:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesMapper'
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpanattributemappingtypesGroup:
|
||||
properties:
|
||||
category:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesGroupCategory'
|
||||
condition:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesCondition'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- orgId
|
||||
- name
|
||||
- category
|
||||
- condition
|
||||
- enabled
|
||||
type: object
|
||||
SpanattributemappingtypesGroupCategory:
|
||||
type: object
|
||||
SpanattributemappingtypesMapper:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesMapperConfig'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
field_context:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesFieldContext'
|
||||
group_id:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- group_id
|
||||
- name
|
||||
- field_context
|
||||
- config
|
||||
- enabled
|
||||
type: object
|
||||
SpanattributemappingtypesMapperConfig:
|
||||
properties:
|
||||
sources:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesMapperSource'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- sources
|
||||
type: object
|
||||
SpanattributemappingtypesMapperOperation:
|
||||
enum:
|
||||
- move
|
||||
- copy
|
||||
type: string
|
||||
SpanattributemappingtypesMapperSource:
|
||||
properties:
|
||||
context:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesFieldContext'
|
||||
key:
|
||||
type: string
|
||||
operation:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesMapperOperation'
|
||||
priority:
|
||||
type: integer
|
||||
required:
|
||||
- key
|
||||
- context
|
||||
- operation
|
||||
- priority
|
||||
type: object
|
||||
SpanattributemappingtypesPostableGroup:
|
||||
properties:
|
||||
category:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesGroupCategory'
|
||||
condition:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesCondition'
|
||||
enabled:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- category
|
||||
- condition
|
||||
type: object
|
||||
SpanattributemappingtypesPostableMapper:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesMapperConfig'
|
||||
enabled:
|
||||
type: boolean
|
||||
field_context:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesFieldContext'
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- field_context
|
||||
- config
|
||||
type: object
|
||||
SpanattributemappingtypesUpdatableGroup:
|
||||
properties:
|
||||
condition:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesCondition'
|
||||
enabled:
|
||||
nullable: true
|
||||
type: boolean
|
||||
name:
|
||||
nullable: true
|
||||
type: string
|
||||
type: object
|
||||
SpanattributemappingtypesUpdatableMapper:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesMapperConfig'
|
||||
enabled:
|
||||
nullable: true
|
||||
type: boolean
|
||||
field_context:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesFieldContext'
|
||||
type: object
|
||||
TelemetrytypesFieldContext:
|
||||
enum:
|
||||
- metric
|
||||
@@ -8876,6 +9182,487 @@ paths:
|
||||
summary: Updates my service account
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/span_attribute_mapping_groups:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns all span attribute mapping groups for the authenticated
|
||||
org.
|
||||
operationId: ListSpanAttributeMappingGroups
|
||||
parameters:
|
||||
- explode: true
|
||||
in: query
|
||||
name: category
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesGroupCategory'
|
||||
style: deepObject
|
||||
- in: query
|
||||
name: enabled
|
||||
schema:
|
||||
nullable: true
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesGettableGroups'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List span attribute mapping groups
|
||||
tags:
|
||||
- span-attribute-mapping
|
||||
post:
|
||||
deprecated: false
|
||||
description: Creates a new span attribute mapping group for the org.
|
||||
operationId: CreateMappingGroup
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesPostableGroup'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesGroup'
|
||||
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 span attribute mapping group
|
||||
tags:
|
||||
- span-attribute-mapping
|
||||
/api/v1/span_attribute_mapping_groups/{groupId}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Hard-deletes a mapping group and cascades to all its mappers.
|
||||
operationId: DeleteMappingGroup
|
||||
parameters:
|
||||
- in: path
|
||||
name: groupId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
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: Delete a span attribute mapping group
|
||||
tags:
|
||||
- span-attribute-mapping
|
||||
patch:
|
||||
deprecated: false
|
||||
description: Partially updates an existing mapping group's name, condition,
|
||||
or enabled state.
|
||||
operationId: UpdateMappingGroup
|
||||
parameters:
|
||||
- in: path
|
||||
name: groupId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesUpdatableGroup'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Update a span attribute mapping group
|
||||
tags:
|
||||
- span-attribute-mapping
|
||||
/api/v1/span_attribute_mapping_groups/{groupId}/mappers:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns all attribute mappers belonging to a mapping group.
|
||||
operationId: ListMappers
|
||||
parameters:
|
||||
- in: path
|
||||
name: groupId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesGettableMappers'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List span attribute mappers for a group
|
||||
tags:
|
||||
- span-attribute-mapping
|
||||
post:
|
||||
deprecated: false
|
||||
description: Adds a new attribute mapper to the specified mapping group.
|
||||
operationId: CreateMapper
|
||||
parameters:
|
||||
- in: path
|
||||
name: groupId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesPostableMapper'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesMapper'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"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 span attribute mapper
|
||||
tags:
|
||||
- span-attribute-mapping
|
||||
/api/v1/span_attribute_mapping_groups/{groupId}/mappers/{mapperId}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Hard-deletes a mapper from a mapping group.
|
||||
operationId: DeleteMapper
|
||||
parameters:
|
||||
- in: path
|
||||
name: groupId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: mapperId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
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: Delete a span attribute mapper
|
||||
tags:
|
||||
- span-attribute-mapping
|
||||
patch:
|
||||
deprecated: false
|
||||
description: Partially updates an existing mapper's field context, config, or
|
||||
enabled state.
|
||||
operationId: UpdateMapper
|
||||
parameters:
|
||||
- in: path
|
||||
name: groupId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: mapperId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpanattributemappingtypesUpdatableMapper'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Update a span attribute mapper
|
||||
tags:
|
||||
- span-attribute-mapping
|
||||
/api/v1/testChannel:
|
||||
post:
|
||||
deprecated: true
|
||||
@@ -9853,6 +10640,72 @@ paths:
|
||||
summary: Health check
|
||||
tags:
|
||||
- health
|
||||
/api/v2/infra_monitoring/hosts:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of hosts with key infrastructure metrics:
|
||||
CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute
|
||||
load average. Each host includes its current status (active/inactive based
|
||||
on metrics reported in the last 10 minutes) and metadata attributes (e.g.,
|
||||
os.type). Supports filtering via a filter expression, filtering by host status,
|
||||
custom groupBy to aggregate hosts by any attribute, ordering by any of the
|
||||
five metrics, and pagination via offset/limit. The response type is ''list''
|
||||
for the default host.name grouping or ''grouped_list'' for custom groupBy
|
||||
keys. Also reports missing required metrics and whether the requested time
|
||||
range falls before the data retention boundary.'
|
||||
operationId: ListHosts
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableHosts'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHosts'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List Hosts for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/livez:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
# Integration Tests
|
||||
|
||||
SigNoz uses integration tests to verify that different components work together correctly in a real environment. These tests run against actual services (ClickHouse, PostgreSQL, etc.) to ensure end-to-end functionality.
|
||||
|
||||
## How to set up the integration test environment?
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before running integration tests, ensure you have the following installed:
|
||||
|
||||
- Python 3.13+
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
- Docker (for containerized services)
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Navigate to the integration tests directory:
|
||||
```bash
|
||||
cd tests/integration
|
||||
```
|
||||
|
||||
2. Install dependencies using uv:
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
> **_NOTE:_** the build backend could throw an error while installing `psycopg2`, pleae see https://www.psycopg.org/docs/install.html#build-prerequisites
|
||||
|
||||
### Starting the Test Environment
|
||||
|
||||
To spin up all the containers necessary for writing integration tests and keep them running:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/bootstrap/setup.py::test_setup
|
||||
```
|
||||
|
||||
This command will:
|
||||
- Start all required services (ClickHouse, PostgreSQL, Zookeeper, etc.)
|
||||
- Keep containers running due to the `--reuse` flag
|
||||
- Verify that the setup is working correctly
|
||||
|
||||
### Stopping the Test Environment
|
||||
|
||||
When you're done writing integration tests, clean up the environment:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --teardown -s src/bootstrap/setup.py::test_teardown
|
||||
```
|
||||
|
||||
This will destroy the running integration test setup and clean up resources.
|
||||
|
||||
## Understanding the Integration Test Framework
|
||||
|
||||
Python and pytest form the foundation of the integration testing framework. Testcontainers are used to spin up disposable integration environments. Wiremock is used to spin up **test doubles** of other services.
|
||||
|
||||
- **Why Python/pytest?** It's expressive, low-boilerplate, and has powerful fixture capabilities that make integration testing straightforward. Extensive libraries for HTTP requests, JSON handling, and data analysis (numpy) make it easier to test APIs and verify data
|
||||
- **Why testcontainers?** They let us spin up isolated dependencies that match our production environment without complex setup.
|
||||
- **Why wiremock?** Well maintained, documented and extensible.
|
||||
|
||||
```
|
||||
.
|
||||
├── conftest.py
|
||||
├── fixtures
|
||||
│ ├── __init__.py
|
||||
│ ├── auth.py
|
||||
│ ├── clickhouse.py
|
||||
│ ├── fs.py
|
||||
│ ├── http.py
|
||||
│ ├── migrator.py
|
||||
│ ├── network.py
|
||||
│ ├── postgres.py
|
||||
│ ├── signoz.py
|
||||
│ ├── sql.py
|
||||
│ ├── sqlite.py
|
||||
│ ├── types.py
|
||||
│ └── zookeeper.py
|
||||
├── uv.lock
|
||||
├── pyproject.toml
|
||||
└── src
|
||||
└── bootstrap
|
||||
├── __init__.py
|
||||
├── 01_database.py
|
||||
├── 02_register.py
|
||||
└── 03_license.py
|
||||
```
|
||||
|
||||
Each test suite follows some important principles:
|
||||
|
||||
1. **Organization**: Test suites live under `src/` in self-contained packages. Fixtures (a pytest concept) live inside `fixtures/`.
|
||||
2. **Execution Order**: Files are prefixed with two-digit numbers (`01_`, `02_`, `03_`) to ensure sequential execution.
|
||||
3. **Time Constraints**: Each suite should complete in under 10 minutes (setup takes ~4 mins).
|
||||
|
||||
### Test Suite Design
|
||||
|
||||
Test suites should target functional domains or subsystems within SigNoz. When designing a test suite, consider these principles:
|
||||
|
||||
- **Functional Cohesion**: Group tests around a specific capability or service boundary
|
||||
- **Data Flow**: Follow the path of data through related components
|
||||
- **Change Patterns**: Components frequently modified together should be tested together
|
||||
|
||||
The exact boundaries for modules are intentionally flexible, allowing teams to define logical groupings based on their specific context and knowledge of the system.
|
||||
|
||||
Eg: The **bootstrap** integration test suite validates core system functionality:
|
||||
|
||||
- Database initialization
|
||||
- Version check
|
||||
|
||||
Other test suites can be **pipelines, auth, querier.**
|
||||
|
||||
## How to write an integration test?
|
||||
|
||||
Now start writing an integration test. Create a new file `src/bootstrap/05_version.py` and paste the following:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
def test_version(signoz: types.SigNoz) -> None:
|
||||
response = requests.get(signoz.self.host_config.get("/api/v1/version"), timeout=2)
|
||||
logger.info(response)
|
||||
```
|
||||
|
||||
We have written a simple test which calls the `version` endpoint of the container in step 1. In **order to just run this function, run the following command:**
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/bootstrap/05_version.py::test_version
|
||||
```
|
||||
|
||||
> Note: The `--reuse` flag is used to reuse the environment if it is already running. Always use this flag when writing and running integration tests. If you don't use this flag, the environment will be destroyed and recreated every time you run the test.
|
||||
|
||||
Here's another example of how to write a more comprehensive integration test:
|
||||
|
||||
```python
|
||||
from http import HTTPStatus
|
||||
import requests
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
def test_user_registration(signoz: types.SigNoz) -> None:
|
||||
"""Test user registration functionality."""
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/register"),
|
||||
json={
|
||||
"name": "testuser",
|
||||
"orgId": "",
|
||||
"orgName": "test.org",
|
||||
"email": "test@example.com",
|
||||
"password": "password123Z$",
|
||||
},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["setupCompleted"] is True
|
||||
```
|
||||
|
||||
## How to run integration tests?
|
||||
|
||||
### Running All Tests
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/
|
||||
```
|
||||
|
||||
### Running Specific Test Categories
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/<suite>
|
||||
|
||||
# Run querier tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/querier/
|
||||
# Run auth tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/auth/
|
||||
```
|
||||
|
||||
### Running Individual Tests
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/<suite>/<file>.py::test_name
|
||||
|
||||
# Run test_register in file 01_register.py in passwordauthn suite
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse src/passwordauthn/01_register.py::test_register
|
||||
```
|
||||
|
||||
## How to configure different options for integration tests?
|
||||
|
||||
Tests can be configured using pytest options:
|
||||
|
||||
- `--sqlstore-provider` - Choose database provider (default: postgres)
|
||||
- `--sqlite-mode` - SQLite journal mode: `delete` or `wal` (default: delete). Only relevant when `--sqlstore-provider=sqlite`.
|
||||
- `--postgres-version` - PostgreSQL version (default: 15)
|
||||
- `--clickhouse-version` - ClickHouse version (default: 25.5.6)
|
||||
- `--zookeeper-version` - Zookeeper version (default: 3.7.1)
|
||||
|
||||
Example:
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postgres-version=14 src/auth/
|
||||
```
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- **Always use the `--reuse` flag** when setting up the environment to keep containers running
|
||||
- **Use the `--teardown` flag** when cleaning up to avoid resource leaks
|
||||
- **Follow the naming convention** with two-digit numeric prefixes (`01_`, `02_`) for test execution order
|
||||
- **Use proper timeouts** in HTTP requests to avoid hanging tests
|
||||
- **Clean up test data** between tests to avoid interference
|
||||
- **Use descriptive test names** that clearly indicate what is being tested
|
||||
- **Leverage fixtures** for common setup and authentication
|
||||
- **Test both success and failure scenarios** to ensure robust functionality
|
||||
- **`--sqlite-mode=wal` does not work on macOS.** The integration test environment runs SigNoz inside a Linux container with the SQLite database file mounted from the macOS host. WAL mode requires shared memory between connections, and connections crossing the VM boundary (macOS host ↔ Linux container) cannot share the WAL index, resulting in `SQLITE_IOERR_SHORT_READ`. WAL mode is tested in CI on Linux only.
|
||||
@@ -15,7 +15,6 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
|
||||
- [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`
|
||||
|
||||
261
docs/contributing/tests/e2e.md
Normal file
261
docs/contributing/tests/e2e.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# E2E Tests
|
||||
|
||||
SigNoz uses end-to-end tests to verify the frontend works correctly against a real backend. These tests use Playwright to drive a real browser against a containerized SigNoz stack that pytest brings up — the same fixture graph integration tests use, with an extra HTTP seeder container for per-spec telemetry seeding.
|
||||
|
||||
## How to set up the E2E test environment?
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before running E2E tests, ensure you have the following installed:
|
||||
|
||||
- Python 3.13+
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
- Docker (for containerized services)
|
||||
- Node 18+ and Yarn
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Install Python deps for the shared tests project:
|
||||
```bash
|
||||
cd tests
|
||||
uv sync
|
||||
```
|
||||
|
||||
2. Install Node deps and Playwright browsers:
|
||||
```bash
|
||||
cd e2e
|
||||
yarn install
|
||||
yarn install:browsers # one-time Playwright browser install
|
||||
```
|
||||
|
||||
### Starting the Test Environment
|
||||
|
||||
To spin up the backend stack (SigNoz, ClickHouse, Postgres, Zookeeper, Zeus mock, gateway mock, seeder, migrator-with-web) and keep it running:
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse --with-web \
|
||||
e2e/bootstrap/setup.py::test_setup
|
||||
```
|
||||
|
||||
This command will:
|
||||
- Bring up all containers via pytest fixtures
|
||||
- Register the admin user (`admin@integration.test` / `password123Z$`)
|
||||
- Apply the enterprise license (via a WireMock stub of Zeus) and dismiss the org-onboarding prompt so specs can navigate directly to feature pages
|
||||
- Start the HTTP seeder container (`tests/seeder/` — exposing `/telemetry/{traces,logs,metrics}` POST + DELETE)
|
||||
- Write backend coordinates to `tests/e2e/.env.local` (loaded by `playwright.config.ts` via dotenv)
|
||||
- Keep containers running via the `--reuse` flag
|
||||
|
||||
The `--with-web` flag builds the frontend into the SigNoz container — required for E2E. The build takes ~4 mins on a cold start.
|
||||
|
||||
### Stopping the Test Environment
|
||||
|
||||
When you're done writing E2E tests, clean up the environment:
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --teardown \
|
||||
e2e/bootstrap/setup.py::test_teardown
|
||||
```
|
||||
|
||||
## Understanding the E2E Test Framework
|
||||
|
||||
Playwright drives a real browser (Chromium / Firefox / WebKit) against the running SigNoz frontend. The backend is brought up by the same pytest fixture graph integration tests use, so both suites share one source of truth for container lifecycle, license seeding, and test-user accounts.
|
||||
|
||||
- **Why Playwright?** First-class TypeScript support, network interception, automatic wait-for-visibility, built-in trace viewer that captures every request/response the UI triggers — so specs rarely need separate API probes alongside UI clicks.
|
||||
- **Why pytest for lifecycle?** The integration suite already owns container bring-up. Reusing it keeps the E2E stack exactly in sync with the integration stack and avoids a parallel lifecycle framework.
|
||||
- **Why a separate seeder container?** Per-spec telemetry seeding (traces / logs / metrics) needs a thin HTTP wrapper around the ClickHouse insert helpers so a browser spec can POST from inside the test. The seeder lives at `tests/seeder/`, is built from `tests/Dockerfile.seeder`, and reuses the same `fixtures/{traces,logs,metrics}.py` as integration tests.
|
||||
|
||||
```
|
||||
tests/
|
||||
├── fixtures/ # shared with integration (see integration.md)
|
||||
├── integration/ # pytest integration suite
|
||||
├── seeder/ # standalone HTTP seeder container
|
||||
│ ├── __init__.py
|
||||
│ ├── Dockerfile
|
||||
│ └── server.py # FastAPI app wrapping fixtures.{traces,logs,metrics}
|
||||
└── e2e/
|
||||
├── package.json
|
||||
├── playwright.config.ts # loads .env + .env.local via dotenv
|
||||
├── .env.example # staging-mode template
|
||||
├── .env.local # generated by bootstrap/setup.py (gitignored)
|
||||
├── bootstrap/
|
||||
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
|
||||
├── fixtures/
|
||||
│ └── auth.ts # authedPage Playwright fixture + per-worker storageState cache
|
||||
├── tests/ # Playwright .spec.ts files, one dir per feature area
|
||||
│ └── alerts/
|
||||
│ └── alerts.spec.ts
|
||||
└── artifacts/ # per-run output (gitignored)
|
||||
├── html/ # HTML reporter output
|
||||
├── json/ # JSON reporter output
|
||||
└── results/ # per-test traces / screenshots / videos on failure
|
||||
```
|
||||
|
||||
Each spec follows these principles:
|
||||
|
||||
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
|
||||
2. **Test titles use `TC-NN`**: `test('TC-01 alerts page — tabs render', ...)`. Preserves ordering at a glance and maps to external coverage tracking.
|
||||
3. **UI-first**: drive flows through the UI. Playwright traces capture every BE request/response the UI triggers, so asserting on UI outcomes implicitly validates BE contracts. Reach for direct `page.request.*` only when the test's *purpose* is asserting a response contract (use `page.waitForResponse` on a UI click) or when a specific UI step is structurally flaky (e.g. Ant DatePicker calendar-cell indices) — and even then try UI first.
|
||||
4. **Self-contained state**: each spec creates what it needs and cleans up in `try/finally`. No global pre-seeding fixtures.
|
||||
|
||||
## How to write an E2E test?
|
||||
|
||||
Create a new file `tests/e2e/tests/alerts/smoke.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
|
||||
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
|
||||
await page.goto('/alerts');
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /configuration/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
The `authedPage` fixture (from `tests/e2e/fixtures/auth.ts`) gives you a `Page` whose browser context is already authenticated as the admin user. First use per worker triggers one login; the resulting `storageState` is held in memory and reused for later requests.
|
||||
|
||||
To run just this test (assuming the stack is up via `test_setup`):
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
npx playwright test tests/alerts/smoke.spec.ts --project=chromium
|
||||
```
|
||||
|
||||
Here's a more comprehensive example that exercises a CRUD flow via the UI:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('TC-02 alerts list — create, toggle, delete', async ({ authedPage: page }) => {
|
||||
await page.goto('/alerts?tab=AlertRules');
|
||||
const name = 'smoke-rule';
|
||||
|
||||
// Seed via UI — click "New Alert", fill form, save.
|
||||
await page.getByRole('button', { name: /new alert/i }).click();
|
||||
await page.getByTestId('alert-name-input').fill(name);
|
||||
// ... fill metric / threshold / save ...
|
||||
|
||||
// Find the row and exercise the action menu.
|
||||
const row = page.locator('tr', { hasText: name });
|
||||
await expect(row).toBeVisible();
|
||||
await row.locator('[data-testid="alert-actions"] button').first().click();
|
||||
|
||||
// waitForResponse captures the network call the UI triggers — no parallel fetch needed.
|
||||
const patchWait = page.waitForResponse(
|
||||
(r) => r.url().includes('/rules/') && r.request().method() === 'PATCH',
|
||||
);
|
||||
await page.getByRole('menuitem').filter({ hasText: /^disable$/i }).click();
|
||||
await patchWait;
|
||||
await expect(row).toContainText(/disabled/i);
|
||||
});
|
||||
```
|
||||
|
||||
### Locator priority
|
||||
|
||||
1. `getByRole('button', { name: 'Submit' })`
|
||||
2. `getByLabel('Email')`
|
||||
3. `getByPlaceholder('...')`
|
||||
4. `getByText('...')`
|
||||
5. `getByTestId('...')`
|
||||
6. `locator('.ant-select')` — last resort (Ant Design dropdowns often have no semantic alternative)
|
||||
|
||||
## How to run E2E tests?
|
||||
|
||||
### Running All Tests
|
||||
|
||||
With the stack already up, from `tests/e2e/`:
|
||||
|
||||
```bash
|
||||
yarn test # headless, all projects
|
||||
```
|
||||
|
||||
### Running Specific Projects
|
||||
|
||||
```bash
|
||||
yarn test:chromium # chromium only
|
||||
yarn test:firefox
|
||||
yarn test:webkit
|
||||
```
|
||||
|
||||
### Running Specific Tests
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
|
||||
# Single feature dir
|
||||
npx playwright test tests/alerts/ --project=chromium
|
||||
|
||||
# Single file
|
||||
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
|
||||
|
||||
# Single test by title grep
|
||||
npx playwright test --project=chromium -g "TC-01"
|
||||
```
|
||||
|
||||
### Iterative modes
|
||||
|
||||
```bash
|
||||
yarn test:ui # Playwright UI mode — watch + step through
|
||||
yarn test:headed # headed browser
|
||||
yarn test:debug # Playwright inspector, pause-on-breakpoint
|
||||
yarn codegen # record-and-replay locator generation
|
||||
yarn report # open the last HTML report (artifacts/html)
|
||||
```
|
||||
|
||||
### Staging fallback
|
||||
|
||||
Point `SIGNOZ_E2E_BASE_URL` at a remote env via `.env` — no local backend bring-up, no `.env.local` generated, Playwright hits the URL directly:
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
cp .env.example .env # fill SIGNOZ_E2E_USERNAME / PASSWORD
|
||||
yarn test:staging
|
||||
```
|
||||
|
||||
## How to configure different options for E2E tests?
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `SIGNOZ_E2E_BASE_URL` | Base URL the browser targets. Written by `bootstrap/setup.py` for local mode; set manually for staging. |
|
||||
| `SIGNOZ_E2E_USERNAME` | Admin email. Bootstrap writes `admin@integration.test`. |
|
||||
| `SIGNOZ_E2E_PASSWORD` | Admin password. Bootstrap writes the integration-test default. |
|
||||
| `SIGNOZ_E2E_SEEDER_URL` | Seeder HTTP base URL — hit by specs that need per-test telemetry. |
|
||||
|
||||
Loading order in `playwright.config.ts`: `.env` first (user-provided, staging), then `.env.local` with `override: true` (bootstrap-generated, local mode). Anything already set in `process.env` at yarn-test time wins because dotenv doesn't touch vars that are already present.
|
||||
|
||||
### Playwright options
|
||||
|
||||
The full `playwright.config.ts` is the source of truth. Common things to tweak:
|
||||
|
||||
- `projects` — Chromium / Firefox / WebKit are enabled by default. Disable to speed up iteration.
|
||||
- `retries` — `2` on CI (`process.env.CI`), `0` locally.
|
||||
- `fullyParallel: true` — files run in parallel by worker; within a file, use `test.describe.configure({ mode: 'serial' })` if tests share list pages / mutate shared state.
|
||||
- `trace: 'on-first-retry'`, `screenshot: 'only-on-failure'`, `video: 'retain-on-failure'` — default diagnostic artifacts land in `artifacts/results/<test>/`.
|
||||
|
||||
### Pytest options (bootstrap side)
|
||||
|
||||
The same pytest flags integration tests expose work here, since E2E reuses the shared fixture graph:
|
||||
|
||||
- `--reuse` — keep containers warm between runs (required for all iteration).
|
||||
- `--teardown` — tear everything down.
|
||||
- `--with-web` — build the frontend into the SigNoz container. **Required for E2E**; integration tests don't need it.
|
||||
- `--sqlstore-provider`, `--postgres-version`, `--clickhouse-version`, etc. — see `docs/contributing/integration.md`.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- **Always use the `--reuse` flag** when setting up the E2E stack. `--with-web` adds a ~4 min frontend build; you only want to pay that once.
|
||||
- **Don't teardown before setup.** `--reuse` correctly handles partially-set-up state, so chaining teardown → setup wastes time.
|
||||
- **Prefer UI-driven flows.** Playwright captures BE requests in the trace; a parallel `fetch` probe is almost always redundant. Drop to `page.request.*` only when the UI can't reach what you need.
|
||||
- **Use `page.waitForResponse` on UI clicks** to assert BE contracts — it still exercises the UI trigger path.
|
||||
- **Title every test `TC-NN <short description>`** — keeps the suite navigable and reportable.
|
||||
- **Split by resource, not by regression suite.** One spec per feature resource; cross-resource junction concerns (cascade-delete, linked-edit) get their own file.
|
||||
- **Use short descriptive resource names** (`alerts-list-rule`, `labels-rule`, `downtime-once`) — no timestamp disambiguation. Each test owns its resources and cleans up in `try/finally`.
|
||||
- **Never commit `test.only`** — a pre-commit check or CI runs with `forbidOnly: true`.
|
||||
- **Prefer explicit waits over `page.waitForTimeout(ms)`.** `await expect(locator).toBeVisible()` is always better than `waitForTimeout(5000)`.
|
||||
- **Unique test names won't save you from shared-tenant state.** When two tests hit the same list page, either serialize (`describe.configure({ mode: 'serial' })`) or isolate cleanup religiously.
|
||||
- **Artifacts go to `tests/e2e/artifacts/`** — HTML report at `artifacts/html`, traces at `artifacts/results/<test>/`. All gitignored; archive the dir in CI.
|
||||
251
docs/contributing/tests/integration.md
Normal file
251
docs/contributing/tests/integration.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Integration Tests
|
||||
|
||||
SigNoz uses integration tests to verify that different components work together correctly in a real environment. These tests run against actual services (ClickHouse, PostgreSQL, SigNoz, Zeus mock, Keycloak, etc.) spun up as containers, so suites exercise the same code paths production does.
|
||||
|
||||
## How to set up the integration test environment?
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before running integration tests, ensure you have the following installed:
|
||||
|
||||
- Python 3.13+
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
- Docker (for containerized services)
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Navigate to the shared tests project:
|
||||
```bash
|
||||
cd tests
|
||||
```
|
||||
|
||||
2. Install dependencies using uv:
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
> **_NOTE:_** the build backend could throw an error while installing `psycopg2`, please see https://www.psycopg.org/docs/install.html#build-prerequisites
|
||||
|
||||
### Starting the Test Environment
|
||||
|
||||
To spin up all the containers necessary for writing integration tests and keep them running:
|
||||
|
||||
```bash
|
||||
make py-test-setup
|
||||
```
|
||||
|
||||
Under the hood this runs, from `tests/`:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/bootstrap/setup.py::test_setup
|
||||
```
|
||||
|
||||
This command will:
|
||||
- Start all required services (ClickHouse, PostgreSQL, Zookeeper, SigNoz, Zeus mock, gateway mock)
|
||||
- Register an admin user
|
||||
- Keep containers running via the `--reuse` flag
|
||||
|
||||
### Stopping the Test Environment
|
||||
|
||||
When you're done writing integration tests, clean up the environment:
|
||||
|
||||
```bash
|
||||
make py-test-teardown
|
||||
```
|
||||
|
||||
Which runs:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --teardown integration/bootstrap/setup.py::test_teardown
|
||||
```
|
||||
|
||||
This destroys the running integration test setup and cleans up resources.
|
||||
|
||||
## Understanding the Integration Test Framework
|
||||
|
||||
Python and pytest form the foundation of the integration testing framework. Testcontainers are used to spin up disposable integration environments. WireMock is used to spin up **test doubles** of external services (Zeus cloud API, gateway, etc.).
|
||||
|
||||
- **Why Python/pytest?** It's expressive, low-boilerplate, and has powerful fixture capabilities that make integration testing straightforward. Extensive libraries for HTTP requests, JSON handling, and data analysis (numpy) make it easier to test APIs and verify data.
|
||||
- **Why testcontainers?** They let us spin up isolated dependencies that match our production environment without complex setup.
|
||||
- **Why WireMock?** Well maintained, documented, and extensible.
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # pytest_plugins registration
|
||||
├── pyproject.toml
|
||||
├── uv.lock
|
||||
├── fixtures/ # shared fixture library (flat package)
|
||||
│ ├── __init__.py
|
||||
│ ├── auth.py # admin/editor/viewer users, tokens, license
|
||||
│ ├── clickhouse.py
|
||||
│ ├── http.py # WireMock helpers
|
||||
│ ├── keycloak.py # IdP container
|
||||
│ ├── postgres.py
|
||||
│ ├── signoz.py # SigNoz-backend container
|
||||
│ ├── sql.py
|
||||
│ ├── types.py
|
||||
│ └── ... # logs, metrics, traces, alerts, dashboards, ...
|
||||
├── integration/
|
||||
│ ├── bootstrap/
|
||||
│ │ └── setup.py # test_setup / test_teardown
|
||||
│ ├── testdata/ # JSON / JSONL / YAML inputs per suite
|
||||
│ └── tests/ # one directory per feature area
|
||||
│ ├── alerts/
|
||||
│ │ ├── 01_*.py # numbered suite files
|
||||
│ │ └── conftest.py # optional suite-local fixtures
|
||||
│ ├── auditquerier/
|
||||
│ ├── cloudintegrations/
|
||||
│ ├── dashboard/
|
||||
│ ├── passwordauthn/
|
||||
│ ├── querier/
|
||||
│ └── ...
|
||||
└── e2e/ # Playwright suite (see docs/contributing/e2e.md)
|
||||
```
|
||||
|
||||
Each test suite follows these principles:
|
||||
|
||||
1. **Organization**: Suites live under `tests/integration/tests/` in self-contained packages. Shared fixtures live in the top-level `tests/fixtures/` package so the e2e tree can reuse them.
|
||||
2. **Execution Order**: Files are prefixed with two-digit numbers (`01_`, `02_`, `03_`) to ensure sequential execution when tests depend on ordering.
|
||||
3. **Time Constraints**: Each suite should complete in under 10 minutes (setup takes ~4 mins).
|
||||
|
||||
### Test Suite Design
|
||||
|
||||
Test suites should target functional domains or subsystems within SigNoz. When designing a test suite, consider these principles:
|
||||
|
||||
- **Functional Cohesion**: Group tests around a specific capability or service boundary
|
||||
- **Data Flow**: Follow the path of data through related components
|
||||
- **Change Patterns**: Components frequently modified together should be tested together
|
||||
|
||||
The exact boundaries for suites are intentionally flexible, allowing contributors to define logical groupings based on their domain knowledge. Current suites cover alerts, audit querier, callback authn, cloud integrations, dashboards, ingestion keys, logs pipelines, password authn, preferences, querier, raw export data, roles, root user, service accounts, and TTL.
|
||||
|
||||
## How to write an integration test?
|
||||
|
||||
Now start writing an integration test. Create a new file `tests/integration/tests/bootstrap/01_version.py` and paste the following:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def test_version(signoz: types.SigNoz) -> None:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/version"),
|
||||
timeout=2,
|
||||
)
|
||||
logger.info(response)
|
||||
```
|
||||
|
||||
We have written a simple test which calls the `version` endpoint of the SigNoz backend. **To run just this function, run the following command:**
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse \
|
||||
integration/tests/bootstrap/01_version.py::test_version
|
||||
```
|
||||
|
||||
> **Note:** The `--reuse` flag is used to reuse the environment if it is already running. Always use this flag when writing and running integration tests. Without it the environment is destroyed and recreated every run.
|
||||
|
||||
Here's another example of how to write a more comprehensive integration test:
|
||||
|
||||
```python
|
||||
from http import HTTPStatus
|
||||
import requests
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def test_user_registration(signoz: types.SigNoz) -> None:
|
||||
"""Test user registration functionality."""
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/register"),
|
||||
json={
|
||||
"name": "testuser",
|
||||
"orgId": "",
|
||||
"orgName": "test.org",
|
||||
"email": "test@example.com",
|
||||
"password": "password123Z$",
|
||||
},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["setupCompleted"] is True
|
||||
```
|
||||
|
||||
Test inputs (JSON fixtures, expected payloads) go under `tests/integration/testdata/<suite>/` and are loaded via `fixtures.fs.get_testdata_file_path`.
|
||||
|
||||
## How to run integration tests?
|
||||
|
||||
### Running All Tests
|
||||
|
||||
```bash
|
||||
make py-test
|
||||
```
|
||||
|
||||
Which runs:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv integration/tests/
|
||||
```
|
||||
|
||||
### Running Specific Test Categories
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/<suite>/
|
||||
|
||||
# Run querier tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/querier/
|
||||
# Run passwordauthn tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/passwordauthn/
|
||||
```
|
||||
|
||||
### Running Individual Tests
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse \
|
||||
integration/tests/<suite>/<file>.py::test_name
|
||||
|
||||
# Run test_register in 01_register.py in the passwordauthn suite
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse \
|
||||
integration/tests/passwordauthn/01_register.py::test_register
|
||||
```
|
||||
|
||||
## How to configure different options for integration tests?
|
||||
|
||||
Tests can be configured using pytest options:
|
||||
|
||||
- `--sqlstore-provider` — Choose the SQL store provider (default: `postgres`)
|
||||
- `--sqlite-mode` — SQLite journal mode: `delete` or `wal` (default: `delete`). Only relevant when `--sqlstore-provider=sqlite`.
|
||||
- `--postgres-version` — PostgreSQL version (default: `15`)
|
||||
- `--clickhouse-version` — ClickHouse version (default: `25.5.6`)
|
||||
- `--zookeeper-version` — Zookeeper version (default: `3.7.1`)
|
||||
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.2`)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse \
|
||||
--sqlstore-provider=postgres --postgres-version=14 \
|
||||
integration/tests/passwordauthn/
|
||||
```
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- **Always use the `--reuse` flag** when setting up the environment or running tests to keep containers warm. Without it every run rebuilds the stack (~4 mins).
|
||||
- **Use the `--teardown` flag** only when cleaning up — mixing `--teardown` with `--reuse` is a contradiction.
|
||||
- **Do not pre-emptively teardown before setup.** If the stack is partially up, `--reuse` picks up from wherever it is. `make py-test-teardown` then `make py-test-setup` wastes minutes.
|
||||
- **Follow the naming convention** with two-digit numeric prefixes (`01_`, `02_`) for ordered test execution within a suite.
|
||||
- **Use proper timeouts** in HTTP requests to avoid hanging tests (`timeout=5` is typical).
|
||||
- **Clean up test data** between tests in the same suite to avoid interference — or rely on a fresh SigNoz container if you need full isolation.
|
||||
- **Use descriptive test names** that clearly indicate what is being tested.
|
||||
- **Leverage fixtures** for common setup. The shared fixture package is at `tests/fixtures/` — reuse before adding new ones.
|
||||
- **Test both success and failure scenarios** (4xx / 5xx paths) to ensure robust functionality.
|
||||
- **Run `make py-fmt` and `make py-lint` before committing** Python changes — black + isort + autoflake + pylint.
|
||||
- **`--sqlite-mode=wal` does not work on macOS.** The integration test environment runs SigNoz inside a Linux container with the SQLite database file mounted from the macOS host. WAL mode requires shared memory between connections, and connections crossing the VM boundary (macOS host ↔ Linux container) cannot share the WAL index, resulting in `SQLITE_IOERR_SHORT_READ`. WAL mode is tested in CI on Linux only.
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
@@ -37,6 +38,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
|
||||
providerSettings.MeterProvider,
|
||||
client.WithRequestResponseLog(true),
|
||||
client.WithRetryCount(3),
|
||||
client.WithTimeout(30*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/icons": "0.1.0",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
"@signozhq/ui": "0.0.9",
|
||||
"@signozhq/ui": "0.0.10",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -266,4 +266,4 @@
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
106
frontend/src/api/generated/services/inframonitoring/index.ts
Normal file
106
frontend/src/api/generated/services/inframonitoring/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
MutationFunction,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
InframonitoringtypesPostableHostsDTO,
|
||||
ListHosts200,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary.
|
||||
* @summary List Hosts for Infra Monitoring
|
||||
*/
|
||||
export const listHosts = (
|
||||
inframonitoringtypesPostableHostsDTO: BodyType<InframonitoringtypesPostableHostsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListHosts200>({
|
||||
url: `/api/v2/infra_monitoring/hosts`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostableHostsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListHostsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listHosts'];
|
||||
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 listHosts>>,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listHosts(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListHostsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listHosts>>
|
||||
>;
|
||||
export type ListHostsMutationBody =
|
||||
BodyType<InframonitoringtypesPostableHostsDTO>;
|
||||
export type ListHostsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Hosts for Infra Monitoring
|
||||
*/
|
||||
export const useListHosts = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getListHostsMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
@@ -3053,6 +3053,131 @@ export interface GlobaltypesTokenizerConfigDTO {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesHostFilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
filterByStatus?: InframonitoringtypesHostStatusDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesHostRecordDTOMeta = {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesHostRecordDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
activeHostCount: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
cpu: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
diskUsage: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
hostName: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
inactiveHostCount: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
load15: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
memory: number;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesHostRecordDTOMeta;
|
||||
status: InframonitoringtypesHostStatusDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
wait: number;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesHostStatusDTO {
|
||||
active = 'active',
|
||||
inactive = 'inactive',
|
||||
'' = '',
|
||||
}
|
||||
export interface InframonitoringtypesHostsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
records: InframonitoringtypesHostRecordDTO[] | null;
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableHostsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: InframonitoringtypesHostFilterDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
offset?: number;
|
||||
orderBy?: Querybuildertypesv5OrderByDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesRequiredMetricsCheckDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
missingMetrics: string[] | null;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesResponseTypeDTO {
|
||||
list = 'list',
|
||||
grouped_list = 'grouped_list',
|
||||
}
|
||||
export interface MetricsexplorertypesInspectMetricsRequestDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -5245,6 +5370,194 @@ export interface Sigv4SigV4ConfigDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SpanattributemappingtypesConditionDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
attributes: string[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
resource: string[] | null;
|
||||
}
|
||||
|
||||
export enum SpanattributemappingtypesFieldContextDTO {
|
||||
attribute = 'attribute',
|
||||
resource = 'resource',
|
||||
}
|
||||
export interface SpanattributemappingtypesGettableGroupsDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
items: SpanattributemappingtypesGroupDTO[];
|
||||
}
|
||||
|
||||
export interface SpanattributemappingtypesGettableMappersDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
items: SpanattributemappingtypesMapperDTO[];
|
||||
}
|
||||
|
||||
export interface SpanattributemappingtypesGroupDTO {
|
||||
category: SpanattributemappingtypesGroupCategoryDTO;
|
||||
condition: SpanattributemappingtypesConditionDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface SpanattributemappingtypesGroupCategoryDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SpanattributemappingtypesMapperDTO {
|
||||
config: SpanattributemappingtypesMapperConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
field_context: SpanattributemappingtypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
group_id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface SpanattributemappingtypesMapperConfigDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
sources: SpanattributemappingtypesMapperSourceDTO[] | null;
|
||||
}
|
||||
|
||||
export enum SpanattributemappingtypesMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
}
|
||||
export interface SpanattributemappingtypesMapperSourceDTO {
|
||||
context: SpanattributemappingtypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpanattributemappingtypesMapperOperationDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface SpanattributemappingtypesPostableGroupDTO {
|
||||
category: SpanattributemappingtypesGroupCategoryDTO;
|
||||
condition: SpanattributemappingtypesConditionDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpanattributemappingtypesPostableMapperDTO {
|
||||
config: SpanattributemappingtypesMapperConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
field_context: SpanattributemappingtypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpanattributemappingtypesUpdatableGroupDTO {
|
||||
condition?: SpanattributemappingtypesConditionDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
* @nullable true
|
||||
*/
|
||||
enabled?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
* @nullable true
|
||||
*/
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export interface SpanattributemappingtypesUpdatableMapperDTO {
|
||||
config?: SpanattributemappingtypesMapperConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
* @nullable true
|
||||
*/
|
||||
enabled?: boolean | null;
|
||||
field_context?: SpanattributemappingtypesFieldContextDTO;
|
||||
}
|
||||
|
||||
export enum TelemetrytypesFieldContextDTO {
|
||||
metric = 'metric',
|
||||
log = 'log',
|
||||
@@ -6464,6 +6777,71 @@ export type GetMyServiceAccount200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListSpanAttributeMappingGroupsParams = {
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
category?: SpanattributemappingtypesGroupCategoryDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
* @nullable true
|
||||
* @description undefined
|
||||
*/
|
||||
enabled?: boolean | null;
|
||||
};
|
||||
|
||||
export type ListSpanAttributeMappingGroups200 = {
|
||||
data: SpanattributemappingtypesGettableGroupsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateMappingGroup201 = {
|
||||
data: SpanattributemappingtypesGroupDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteMappingGroupPathParameters = {
|
||||
groupId: string;
|
||||
};
|
||||
export type UpdateMappingGroupPathParameters = {
|
||||
groupId: string;
|
||||
};
|
||||
export type ListMappersPathParameters = {
|
||||
groupId: string;
|
||||
};
|
||||
export type ListMappers200 = {
|
||||
data: SpanattributemappingtypesGettableMappersDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateMapperPathParameters = {
|
||||
groupId: string;
|
||||
};
|
||||
export type CreateMapper201 = {
|
||||
data: SpanattributemappingtypesMapperDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type UpdateMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type ListUsersDeprecated200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -6638,6 +7016,14 @@ export type Healthz503 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListHosts200 = {
|
||||
data: InframonitoringtypesHostsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Livez200 = {
|
||||
data: FactoryResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,790 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CreateMapper201,
|
||||
CreateMapperPathParameters,
|
||||
CreateMappingGroup201,
|
||||
DeleteMapperPathParameters,
|
||||
DeleteMappingGroupPathParameters,
|
||||
ListMappers200,
|
||||
ListMappersPathParameters,
|
||||
ListSpanAttributeMappingGroups200,
|
||||
ListSpanAttributeMappingGroupsParams,
|
||||
RenderErrorResponseDTO,
|
||||
SpanattributemappingtypesPostableGroupDTO,
|
||||
SpanattributemappingtypesPostableMapperDTO,
|
||||
SpanattributemappingtypesUpdatableGroupDTO,
|
||||
SpanattributemappingtypesUpdatableMapperDTO,
|
||||
UpdateMapperPathParameters,
|
||||
UpdateMappingGroupPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns all span attribute mapping groups for the authenticated org.
|
||||
* @summary List span attribute mapping groups
|
||||
*/
|
||||
export const listSpanAttributeMappingGroups = (
|
||||
params?: ListSpanAttributeMappingGroupsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListSpanAttributeMappingGroups200>({
|
||||
url: `/api/v1/span_attribute_mapping_groups`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListSpanAttributeMappingGroupsQueryKey = (
|
||||
params?: ListSpanAttributeMappingGroupsParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/span_attribute_mapping_groups`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getListSpanAttributeMappingGroupsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listSpanAttributeMappingGroups>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListSpanAttributeMappingGroupsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSpanAttributeMappingGroups>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListSpanAttributeMappingGroupsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listSpanAttributeMappingGroups>>
|
||||
> = ({ signal }) => listSpanAttributeMappingGroups(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSpanAttributeMappingGroups>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListSpanAttributeMappingGroupsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listSpanAttributeMappingGroups>>
|
||||
>;
|
||||
export type ListSpanAttributeMappingGroupsQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List span attribute mapping groups
|
||||
*/
|
||||
|
||||
export function useListSpanAttributeMappingGroups<
|
||||
TData = Awaited<ReturnType<typeof listSpanAttributeMappingGroups>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListSpanAttributeMappingGroupsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSpanAttributeMappingGroups>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListSpanAttributeMappingGroupsQueryOptions(
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List span attribute mapping groups
|
||||
*/
|
||||
export const invalidateListSpanAttributeMappingGroups = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListSpanAttributeMappingGroupsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListSpanAttributeMappingGroupsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new span attribute mapping group for the org.
|
||||
* @summary Create a span attribute mapping group
|
||||
*/
|
||||
export const createMappingGroup = (
|
||||
spanattributemappingtypesPostableGroupDTO: BodyType<SpanattributemappingtypesPostableGroupDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateMappingGroup201>({
|
||||
url: `/api/v1/span_attribute_mapping_groups`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spanattributemappingtypesPostableGroupDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateMappingGroupMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createMappingGroup>>,
|
||||
TError,
|
||||
{ data: BodyType<SpanattributemappingtypesPostableGroupDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createMappingGroup>>,
|
||||
TError,
|
||||
{ data: BodyType<SpanattributemappingtypesPostableGroupDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createMappingGroup'];
|
||||
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 createMappingGroup>>,
|
||||
{ data: BodyType<SpanattributemappingtypesPostableGroupDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createMappingGroup(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateMappingGroupMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createMappingGroup>>
|
||||
>;
|
||||
export type CreateMappingGroupMutationBody =
|
||||
BodyType<SpanattributemappingtypesPostableGroupDTO>;
|
||||
export type CreateMappingGroupMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create a span attribute mapping group
|
||||
*/
|
||||
export const useCreateMappingGroup = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createMappingGroup>>,
|
||||
TError,
|
||||
{ data: BodyType<SpanattributemappingtypesPostableGroupDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createMappingGroup>>,
|
||||
TError,
|
||||
{ data: BodyType<SpanattributemappingtypesPostableGroupDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateMappingGroupMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Hard-deletes a mapping group and cascades to all its mappers.
|
||||
* @summary Delete a span attribute mapping group
|
||||
*/
|
||||
export const deleteMappingGroup = ({
|
||||
groupId,
|
||||
}: DeleteMappingGroupPathParameters) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/span_attribute_mapping_groups/${groupId}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteMappingGroupMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMappingGroup>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMappingGroupPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMappingGroup>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMappingGroupPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteMappingGroup'];
|
||||
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 deleteMappingGroup>>,
|
||||
{ pathParams: DeleteMappingGroupPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteMappingGroup(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteMappingGroupMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteMappingGroup>>
|
||||
>;
|
||||
|
||||
export type DeleteMappingGroupMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete a span attribute mapping group
|
||||
*/
|
||||
export const useDeleteMappingGroup = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMappingGroup>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMappingGroupPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteMappingGroup>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMappingGroupPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getDeleteMappingGroupMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Partially updates an existing mapping group's name, condition, or enabled state.
|
||||
* @summary Update a span attribute mapping group
|
||||
*/
|
||||
export const updateMappingGroup = (
|
||||
{ groupId }: UpdateMappingGroupPathParameters,
|
||||
spanattributemappingtypesUpdatableGroupDTO: BodyType<SpanattributemappingtypesUpdatableGroupDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/span_attribute_mapping_groups/${groupId}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spanattributemappingtypesUpdatableGroupDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateMappingGroupMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMappingGroup>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMappingGroupPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesUpdatableGroupDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMappingGroup>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMappingGroupPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesUpdatableGroupDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateMappingGroup'];
|
||||
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 updateMappingGroup>>,
|
||||
{
|
||||
pathParams: UpdateMappingGroupPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesUpdatableGroupDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateMappingGroup(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateMappingGroupMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateMappingGroup>>
|
||||
>;
|
||||
export type UpdateMappingGroupMutationBody =
|
||||
BodyType<SpanattributemappingtypesUpdatableGroupDTO>;
|
||||
export type UpdateMappingGroupMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update a span attribute mapping group
|
||||
*/
|
||||
export const useUpdateMappingGroup = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMappingGroup>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMappingGroupPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesUpdatableGroupDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateMappingGroup>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMappingGroupPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesUpdatableGroupDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateMappingGroupMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns all attribute mappers belonging to a mapping group.
|
||||
* @summary List span attribute mappers for a group
|
||||
*/
|
||||
export const listMappers = (
|
||||
{ groupId }: ListMappersPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListMappers200>({
|
||||
url: `/api/v1/span_attribute_mapping_groups/${groupId}/mappers`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListMappersQueryKey = ({
|
||||
groupId,
|
||||
}: ListMappersPathParameters) => {
|
||||
return [`/api/v1/span_attribute_mapping_groups/${groupId}/mappers`] as const;
|
||||
};
|
||||
|
||||
export const getListMappersQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listMappers>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ groupId }: ListMappersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMappers>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListMappersQueryKey({ groupId });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listMappers>>> = ({
|
||||
signal,
|
||||
}) => listMappers({ groupId }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!groupId,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMappers>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListMappersQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listMappers>>
|
||||
>;
|
||||
export type ListMappersQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List span attribute mappers for a group
|
||||
*/
|
||||
|
||||
export function useListMappers<
|
||||
TData = Awaited<ReturnType<typeof listMappers>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ groupId }: ListMappersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMappers>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListMappersQueryOptions({ groupId }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List span attribute mappers for a group
|
||||
*/
|
||||
export const invalidateListMappers = async (
|
||||
queryClient: QueryClient,
|
||||
{ groupId }: ListMappersPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListMappersQueryKey({ groupId }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new attribute mapper to the specified mapping group.
|
||||
* @summary Create a span attribute mapper
|
||||
*/
|
||||
export const createMapper = (
|
||||
{ groupId }: CreateMapperPathParameters,
|
||||
spanattributemappingtypesPostableMapperDTO: BodyType<SpanattributemappingtypesPostableMapperDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateMapper201>({
|
||||
url: `/api/v1/span_attribute_mapping_groups/${groupId}/mappers`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spanattributemappingtypesPostableMapperDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateMapperMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateMapperPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesPostableMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateMapperPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesPostableMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createMapper'];
|
||||
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 createMapper>>,
|
||||
{
|
||||
pathParams: CreateMapperPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesPostableMapperDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return createMapper(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateMapperMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createMapper>>
|
||||
>;
|
||||
export type CreateMapperMutationBody =
|
||||
BodyType<SpanattributemappingtypesPostableMapperDTO>;
|
||||
export type CreateMapperMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create a span attribute mapper
|
||||
*/
|
||||
export const useCreateMapper = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateMapperPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesPostableMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateMapperPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesPostableMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateMapperMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Hard-deletes a mapper from a mapping group.
|
||||
* @summary Delete a span attribute mapper
|
||||
*/
|
||||
export const deleteMapper = ({
|
||||
groupId,
|
||||
mapperId,
|
||||
}: DeleteMapperPathParameters) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/span_attribute_mapping_groups/${groupId}/mappers/${mapperId}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteMapperMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMapper>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMapperPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMapper>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMapperPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteMapper'];
|
||||
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 deleteMapper>>,
|
||||
{ pathParams: DeleteMapperPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteMapper(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteMapperMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteMapper>>
|
||||
>;
|
||||
|
||||
export type DeleteMapperMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete a span attribute mapper
|
||||
*/
|
||||
export const useDeleteMapper = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMapper>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMapperPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteMapper>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMapperPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getDeleteMapperMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Partially updates an existing mapper's field context, config, or enabled state.
|
||||
* @summary Update a span attribute mapper
|
||||
*/
|
||||
export const updateMapper = (
|
||||
{ groupId, mapperId }: UpdateMapperPathParameters,
|
||||
spanattributemappingtypesUpdatableMapperDTO: BodyType<SpanattributemappingtypesUpdatableMapperDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/span_attribute_mapping_groups/${groupId}/mappers/${mapperId}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spanattributemappingtypesUpdatableMapperDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateMapperMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMapperPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesUpdatableMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMapperPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesUpdatableMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateMapper'];
|
||||
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 updateMapper>>,
|
||||
{
|
||||
pathParams: UpdateMapperPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesUpdatableMapperDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateMapper(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateMapperMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateMapper>>
|
||||
>;
|
||||
export type UpdateMapperMutationBody =
|
||||
BodyType<SpanattributemappingtypesUpdatableMapperDTO>;
|
||||
export type UpdateMapperMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update a span attribute mapper
|
||||
*/
|
||||
export const useUpdateMapper = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMapperPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesUpdatableMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMapperPathParameters;
|
||||
data: BodyType<SpanattributemappingtypesUpdatableMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateMapperMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
@@ -6,15 +6,20 @@ import { PayloadProps, Props } from 'types/api/thirdPartyApis/listOverview';
|
||||
|
||||
const listOverview = async (
|
||||
props: Props,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
const { start, end, show_ip: showIp, filter } = props;
|
||||
try {
|
||||
const response = await axios.post(`/third-party-apis/overview/list`, {
|
||||
start,
|
||||
end,
|
||||
show_ip: showIp,
|
||||
filter,
|
||||
});
|
||||
const response = await axios.post(
|
||||
`/third-party-apis/overview/list`,
|
||||
{
|
||||
start,
|
||||
end,
|
||||
show_ip: showIp,
|
||||
filter,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.text {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.subText {
|
||||
color: var(--foreground);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Typography } from 'antd';
|
||||
import eyesEmojiUrl from 'assets/Images/eyesEmoji.svg';
|
||||
|
||||
import styles from './QueryCancelledPlaceholder.module.scss';
|
||||
|
||||
interface QueryCancelledPlaceholderProps {
|
||||
subText?: string;
|
||||
}
|
||||
|
||||
function QueryCancelledPlaceholder({
|
||||
subText,
|
||||
}: QueryCancelledPlaceholderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.placeholder}>
|
||||
<img className={styles.emoji} src={eyesEmojiUrl} alt="eyes emoji" />
|
||||
<Typography className={styles.text}>
|
||||
Query cancelled.
|
||||
<span className={styles.subText}>
|
||||
{' '}
|
||||
{subText || 'Click "Run Query" to load data.'}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
QueryCancelledPlaceholder.defaultProps = {
|
||||
subText: undefined,
|
||||
};
|
||||
|
||||
export default QueryCancelledPlaceholder;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './QueryCancelledPlaceholder';
|
||||
8
frontend/src/constants/reactQuery.ts
Normal file
8
frontend/src/constants/reactQuery.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Maximum number of retries for a failed react-query request before giving up.
|
||||
* Used as the upper bound in the default `retry` predicate:
|
||||
* `return failureCount < MAX_QUERY_RETRIES;`
|
||||
*
|
||||
* This retries up to 3 times (4 attempts total including the initial request).
|
||||
*/
|
||||
export const MAX_QUERY_RETRIES = 3;
|
||||
@@ -25,7 +25,8 @@ export const REACT_QUERY_KEY = {
|
||||
ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH',
|
||||
GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS',
|
||||
TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE',
|
||||
GET_ALL_ALLERTS: 'GET_ALL_ALLERTS',
|
||||
GET_ALL_ALERTS: 'GET_ALL_ALERTS',
|
||||
ALERT_RULES_CHART_PREVIEW: 'ALERT_RULES_CHART_PREVIEW',
|
||||
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
|
||||
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
|
||||
GET_HOST_LIST: 'GET_HOST_LIST',
|
||||
|
||||
@@ -21,6 +21,7 @@ import { FilterConfirmProps } from 'antd/lib/table/interface';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import getAll from 'api/errors/getAll';
|
||||
import getErrorCounts from 'api/errors/getErrorCounts';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -36,6 +37,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import history from 'lib/history';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
import { useAllErrorsQueryState } from 'pages/AllErrors/QueryStateContext';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
@@ -121,7 +123,13 @@ function AllErrors(): JSX.Element {
|
||||
const { queries } = useResourceAttribute();
|
||||
const compositeData = useGetCompositeQueryParam();
|
||||
|
||||
const [{ isLoading, data }, errorCountResponse] = useQueries([
|
||||
const setIsFetching = useAllErrorsQueryState((s) => s.setIsFetching);
|
||||
const isCancelled = useAllErrorsQueryState((s) => s.isCancelled);
|
||||
|
||||
const [
|
||||
{ isLoading, isFetching: isErrorsFetching, data },
|
||||
errorCountResponse,
|
||||
] = useQueries([
|
||||
{
|
||||
queryKey: ['getAllErrors', updatedPath, maxTime, minTime, compositeData],
|
||||
queryFn: (): Promise<SuccessResponse<PayloadProps> | ErrorResponse> =>
|
||||
@@ -162,6 +170,12 @@ function AllErrors(): JSX.Element {
|
||||
enabled: !loading,
|
||||
},
|
||||
]);
|
||||
|
||||
const isFetching = isErrorsFetching || errorCountResponse.isFetching;
|
||||
useEffect(() => {
|
||||
setIsFetching(isFetching);
|
||||
}, [isFetching, setIsFetching]);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -473,6 +487,12 @@ function AllErrors(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [errorCountResponse.data?.payload]);
|
||||
|
||||
if (isCancelled && !data?.payload?.length) {
|
||||
return (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load exceptions.' />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin, Table } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import emptyStateUrl from 'assets/Icons/emptyState.svg';
|
||||
import cx from 'classnames';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
@@ -23,8 +27,6 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
|
||||
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
|
||||
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
|
||||
import { columnsConfig, formatDataForTable } from '../../utils';
|
||||
@@ -40,6 +42,7 @@ function DomainList(): JSX.Element {
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { currentQuery, handleRunQuery } = useQueryBuilder();
|
||||
const query = useMemo(() => currentQuery?.builder?.queryData[0] || null, [
|
||||
currentQuery,
|
||||
@@ -53,6 +56,19 @@ function DomainList(): JSX.Element {
|
||||
|
||||
const compositeData = useGetCompositeQueryParam();
|
||||
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries([REACT_QUERY_KEY.GET_DOMAINS_LIST]);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient]);
|
||||
|
||||
const handleStageAndRunQuery = useCallback(() => {
|
||||
setIsCancelled(false);
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_DOMAINS_LIST]);
|
||||
handleRunQuery();
|
||||
}, [queryClient, handleRunQuery]);
|
||||
|
||||
const { data, isLoading, isFetching } = useListOverview({
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
@@ -105,6 +121,13 @@ function DomainList(): JSX.Element {
|
||||
[data],
|
||||
);
|
||||
|
||||
// Auto-reset cancelled state when a new fetch starts
|
||||
useEffect(() => {
|
||||
if (isFetching) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isFetching]);
|
||||
|
||||
// Open drawer if selectedDomain is set in URL
|
||||
useEffect(() => {
|
||||
if (selectedDomain && formattedDataForTable?.length > 0) {
|
||||
@@ -119,7 +142,13 @@ function DomainList(): JSX.Element {
|
||||
<section className={cx('api-module-right-section')}>
|
||||
<Toolbar
|
||||
showAutoRefresh={false}
|
||||
rightActions={<RightToolbarActions onStageRunQuery={handleRunQuery} />}
|
||||
rightActions={
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={handleStageAndRunQuery}
|
||||
isLoadingQueries={isFetching}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className={cx('api-monitoring-list-header')}>
|
||||
<QuerySearch
|
||||
@@ -130,38 +159,44 @@ function DomainList(): JSX.Element {
|
||||
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
|
||||
/>
|
||||
</div>
|
||||
{!isFetching && !isLoading && formattedDataForTable.length === 0 && (
|
||||
<div className="no-filtered-domains-message-container">
|
||||
<div className="no-filtered-domains-message-content">
|
||||
<img
|
||||
src={emptyStateUrl}
|
||||
alt="thinking-emoji"
|
||||
className="empty-state-svg"
|
||||
/>
|
||||
{isCancelled && formattedDataForTable.length === 0 && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load API monitoring data.' />
|
||||
)}
|
||||
{!isCancelled &&
|
||||
!isFetching &&
|
||||
!isLoading &&
|
||||
formattedDataForTable.length === 0 && (
|
||||
<div className="no-filtered-domains-message-container">
|
||||
<div className="no-filtered-domains-message-content">
|
||||
<img
|
||||
src={emptyStateUrl}
|
||||
alt="thinking-emoji"
|
||||
className="empty-state-svg"
|
||||
/>
|
||||
|
||||
<div className="no-filtered-domains-message">
|
||||
<div className="no-domain-title">
|
||||
No External API calls detected with applied filters.
|
||||
<div className="no-filtered-domains-message">
|
||||
<div className="no-domain-title">
|
||||
No External API calls detected with applied filters.
|
||||
</div>
|
||||
<div className="no-domain-subtitle">
|
||||
Ensure all HTTP client spans are being sent with kind as{' '}
|
||||
<span className="attribute">Client</span> and url set in{' '}
|
||||
<span className="attribute">url.full</span> or{' '}
|
||||
<span className="attribute">http.url</span> attribute.
|
||||
</div>
|
||||
<a
|
||||
href={DOCLINKS.EXTERNAL_API_MONITORING}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="external-api-doc-link"
|
||||
>
|
||||
Learn how External API monitoring works in SigNoz{' '}
|
||||
<MoveUpRight size={14} />
|
||||
</a>
|
||||
</div>
|
||||
<div className="no-domain-subtitle">
|
||||
Ensure all HTTP client spans are being sent with kind as{' '}
|
||||
<span className="attribute">Client</span> and url set in{' '}
|
||||
<span className="attribute">url.full</span> or{' '}
|
||||
<span className="attribute">http.url</span> attribute.
|
||||
</div>
|
||||
<a
|
||||
href={DOCLINKS.EXTERNAL_API_MONITORING}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="external-api-doc-link"
|
||||
>
|
||||
Learn how External API monitoring works in SigNoz{' '}
|
||||
<MoveUpRight size={14} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{(isFetching || isLoading || formattedDataForTable.length > 0) && (
|
||||
<Table
|
||||
className="api-monitoring-domain-list-table"
|
||||
|
||||
@@ -18,9 +18,16 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
export interface ChartPreviewProps {
|
||||
alertDef: AlertDef;
|
||||
source?: YAxisSource;
|
||||
isCancelled?: boolean;
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
}
|
||||
|
||||
function ChartPreview({ alertDef, source }: ChartPreviewProps): JSX.Element {
|
||||
function ChartPreview({
|
||||
alertDef,
|
||||
source,
|
||||
isCancelled = false,
|
||||
onFetchingStateChange,
|
||||
}: ChartPreviewProps): JSX.Element {
|
||||
const { currentQuery, panelType, stagedQuery } = useQueryBuilder();
|
||||
const {
|
||||
alertType,
|
||||
@@ -88,6 +95,8 @@ function ChartPreview({ alertDef, source }: ChartPreviewProps): JSX.Element {
|
||||
graphType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
setQueryStatus={setQueryStatus}
|
||||
additionalThresholds={thresholdState.thresholds}
|
||||
isCancelled={isCancelled}
|
||||
onFetchingStateChange={onFetchingStateChange}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -102,6 +111,8 @@ function ChartPreview({ alertDef, source }: ChartPreviewProps): JSX.Element {
|
||||
graphType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
setQueryStatus={setQueryStatus}
|
||||
additionalThresholds={thresholdState.thresholds}
|
||||
isCancelled={isCancelled}
|
||||
onFetchingStateChange={onFetchingStateChange}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import QuerySectionComponent from 'container/FormAlertRules/QuerySection';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { getMetricNameFromQueryData } from 'hooks/useGetYAxisUnit';
|
||||
@@ -62,7 +64,24 @@ function QuerySection(): JSX.Element {
|
||||
return currentQueryKey !== stagedQueryKey;
|
||||
}, [currentQuery, alertType, thresholdState, stagedQuery]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient]);
|
||||
|
||||
const runQueryHandler = useCallback(() => {
|
||||
setIsCancelled(false);
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
|
||||
// Reset the source param when the query is changed
|
||||
// Then manually run the query
|
||||
if (source === YAxisSource.DASHBOARDS && didQueryChange) {
|
||||
@@ -76,6 +95,7 @@ function QuerySection(): JSX.Element {
|
||||
currentQuery,
|
||||
didQueryChange,
|
||||
handleRunQuery,
|
||||
queryClient,
|
||||
redirectWithQueryBuilderData,
|
||||
source,
|
||||
]);
|
||||
@@ -106,7 +126,12 @@ function QuerySection(): JSX.Element {
|
||||
return (
|
||||
<div className="query-section">
|
||||
<Stepper stepNumber={1} label="Define the query" />
|
||||
<ChartPreview alertDef={alertDef} source={source} />
|
||||
<ChartPreview
|
||||
alertDef={alertDef}
|
||||
source={source}
|
||||
isCancelled={isCancelled}
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
/>
|
||||
<div className="query-section-tabs">
|
||||
<div className="query-section-query-actions">
|
||||
{tabs.map((tab) => (
|
||||
@@ -130,6 +155,8 @@ function QuerySection(): JSX.Element {
|
||||
setQueryCategory={onQueryCategoryChange}
|
||||
alertType={alertType}
|
||||
runQuery={runQueryHandler}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
alertDef={alertDef}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
key={currentQuery.queryType}
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
isStackedBarChart: isStackedBarChart,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
};
|
||||
return <BarChartTooltip {...tooltipProps} />;
|
||||
},
|
||||
@@ -46,6 +47,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
isStackedBarChart,
|
||||
rest.canPinTooltip,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function ChartWrapper({
|
||||
showTooltip = true,
|
||||
showLegend = true,
|
||||
canPinTooltip = false,
|
||||
pinKey,
|
||||
onClick,
|
||||
syncMode,
|
||||
syncKey,
|
||||
onDestroy = noop,
|
||||
@@ -101,6 +103,8 @@ export default function ChartWrapper({
|
||||
<TooltipPlugin
|
||||
config={config}
|
||||
canPinTooltip={canPinTooltip}
|
||||
pinKey={pinKey}
|
||||
onClick={onClick}
|
||||
syncMode={syncMode}
|
||||
maxWidth={Math.max(
|
||||
TOOLTIP_MIN_WIDTH,
|
||||
|
||||
@@ -26,10 +26,11 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
|
||||
...props,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
};
|
||||
return <HistogramTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customTooltip, rest.yAxisUnit, rest.decimalPrecision],
|
||||
[customTooltip, rest.yAxisUnit, rest.decimalPrecision, rest.canPinTooltip],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,10 +21,17 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
};
|
||||
return <TimeSeriesTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
|
||||
[
|
||||
customTooltip,
|
||||
rest.timezone,
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
rest.canPinTooltip,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,12 @@ interface BaseChartProps {
|
||||
showTooltip?: boolean;
|
||||
showLegend?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
|
||||
pinKey?: string;
|
||||
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
|
||||
onClick?: (clickData: TooltipClickData) => void;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
|
||||
@@ -121,6 +121,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
}}
|
||||
canPinTooltip
|
||||
plotRef={onPlotRef}
|
||||
onDestroy={onPlotDestroy}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
|
||||
@@ -89,6 +89,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onDestroy={(): void => {
|
||||
uPlotRef.current = null;
|
||||
}}
|
||||
canPinTooltip
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
isQueriesMerged={widget.mergeAllActiveQueries}
|
||||
|
||||
@@ -112,6 +112,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
}}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
|
||||
@@ -4,12 +4,14 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import Spinner from 'components/Spinner';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import AnomalyAlertEvaluationView from 'container/AnomalyAlertEvaluationView';
|
||||
import { INITIAL_CRITICAL_THRESHOLD } from 'container/CreateAlertV2/context/constants';
|
||||
import { Threshold } from 'container/CreateAlertV2/context/types';
|
||||
@@ -69,6 +71,8 @@ export interface ChartPreviewProps {
|
||||
setQueryStatus?: (status: string) => void;
|
||||
showSideLegend?: boolean;
|
||||
additionalThresholds?: Threshold[];
|
||||
isCancelled?: boolean;
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
@@ -86,6 +90,8 @@ function ChartPreview({
|
||||
setQueryStatus,
|
||||
showSideLegend = false,
|
||||
additionalThresholds,
|
||||
isCancelled = false,
|
||||
onFetchingStateChange,
|
||||
}: ChartPreviewProps): JSX.Element | null {
|
||||
const { t } = useTranslation('alerts');
|
||||
const dispatch = useDispatch();
|
||||
@@ -185,7 +191,7 @@ function ChartPreview({
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey: [
|
||||
'chartPreview',
|
||||
REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW,
|
||||
userQueryKey || JSON.stringify(query),
|
||||
selectedInterval,
|
||||
minTime,
|
||||
@@ -193,9 +199,14 @@ function ChartPreview({
|
||||
alertDef?.ruleType,
|
||||
],
|
||||
enabled: canQuery,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onFetchingStateChange?.(queryResponse.isFetching);
|
||||
}, [queryResponse.isFetching, onFetchingStateChange]);
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect((): void => {
|
||||
@@ -334,11 +345,16 @@ function ChartPreview({
|
||||
|
||||
const chartData = getUPlotChartData(queryResponse?.data?.payload);
|
||||
|
||||
const hasResultData = !!queryResponse?.data?.payload?.data?.result?.length;
|
||||
|
||||
const isAnomalyDetectionAlert =
|
||||
alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
|
||||
|
||||
const chartDataAvailable =
|
||||
chartData && !queryResponse.isError && !queryResponse.isLoading;
|
||||
chartData &&
|
||||
hasResultData &&
|
||||
!queryResponse.isLoading &&
|
||||
(!queryResponse.isError || isCancelled);
|
||||
|
||||
const isAnomalyDetectionEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
|
||||
@@ -359,10 +375,14 @@ function ChartPreview({
|
||||
{queryResponse.isLoading && (
|
||||
<Spinner size="large" tip="Loading..." height="100%" />
|
||||
)}
|
||||
{(queryResponse?.isError || queryResponse?.error) && (
|
||||
{(queryResponse?.isError || queryResponse?.error) && !isCancelled && (
|
||||
<ErrorInPlace error={queryResponse.error as APIError} />
|
||||
)}
|
||||
|
||||
{isCancelled && !queryResponse.isLoading && !hasResultData && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load the chart preview.' />
|
||||
)}
|
||||
|
||||
{chartDataAvailable && !isAnomalyDetectionAlert && (
|
||||
<GridPanelSwitch
|
||||
options={options}
|
||||
@@ -403,6 +423,8 @@ ChartPreview.defaultProps = {
|
||||
setQueryStatus: (): void => {},
|
||||
showSideLegend: false,
|
||||
additionalThresholds: undefined,
|
||||
isCancelled: false,
|
||||
onFetchingStateChange: undefined,
|
||||
};
|
||||
|
||||
export default ChartPreview;
|
||||
|
||||
@@ -35,6 +35,8 @@ function QuerySection({
|
||||
setQueryCategory,
|
||||
alertType,
|
||||
runQuery,
|
||||
isLoadingQueries,
|
||||
handleCancelQuery,
|
||||
alertDef,
|
||||
panelType,
|
||||
ruleId,
|
||||
@@ -226,6 +228,8 @@ function QuerySection({
|
||||
queryType: queryCategory,
|
||||
});
|
||||
}}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
@@ -245,7 +249,11 @@ function QuerySection({
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
<span style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
<RunQueryBtn onStageRunQuery={runQuery} />
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={runQuery}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
items={items}
|
||||
@@ -287,6 +295,8 @@ interface QuerySectionProps {
|
||||
setQueryCategory: (n: EQueryType) => void;
|
||||
alertType: AlertTypes;
|
||||
runQuery: VoidFunction;
|
||||
isLoadingQueries: boolean;
|
||||
handleCancelQuery: () => void;
|
||||
alertDef: AlertDef;
|
||||
panelType: PANEL_TYPES;
|
||||
ruleId: string;
|
||||
|
||||
@@ -136,6 +136,19 @@ function FormAlertRules({
|
||||
|
||||
// use query client
|
||||
const ruleCache = useQueryClient();
|
||||
const [isChartQueryCancelled, setIsChartQueryCancelled] = useState(false);
|
||||
const [isLoadingAlertQuery, setIsLoadingAlertQuery] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingAlertQuery) {
|
||||
setIsChartQueryCancelled(false);
|
||||
}
|
||||
}, [isLoadingAlertQuery]);
|
||||
|
||||
const handleCancelAlertQuery = useCallback(() => {
|
||||
ruleCache.cancelQueries(REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW);
|
||||
setIsChartQueryCancelled(true);
|
||||
}, [ruleCache]);
|
||||
|
||||
const isNewRule = !ruleId || isEmpty(ruleId);
|
||||
|
||||
@@ -702,6 +715,8 @@ function FormAlertRules({
|
||||
yAxisUnit={yAxisUnit || ''}
|
||||
graphType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
setQueryStatus={setQueryStatus}
|
||||
isCancelled={isChartQueryCancelled}
|
||||
onFetchingStateChange={setIsLoadingAlertQuery}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -720,6 +735,8 @@ function FormAlertRules({
|
||||
yAxisUnit={yAxisUnit || ''}
|
||||
graphType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
setQueryStatus={setQueryStatus}
|
||||
isCancelled={isChartQueryCancelled}
|
||||
onFetchingStateChange={setIsLoadingAlertQuery}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -902,7 +919,15 @@ function FormAlertRules({
|
||||
queryCategory={currentQuery.queryType}
|
||||
setQueryCategory={onQueryCategoryChange}
|
||||
alertType={alertType || AlertTypes.METRICS_BASED_ALERT}
|
||||
runQuery={(): void => handleRunQuery()}
|
||||
runQuery={(): void => {
|
||||
setIsChartQueryCancelled(false);
|
||||
ruleCache.invalidateQueries([
|
||||
REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW,
|
||||
]);
|
||||
handleRunQuery();
|
||||
}}
|
||||
isLoadingQueries={isLoadingAlertQuery}
|
||||
handleCancelQuery={handleCancelAlertQuery}
|
||||
alertDef={alertDef}
|
||||
panelType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
key={currentQuery.queryType}
|
||||
|
||||
@@ -6,6 +6,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux'; // old code, TODO: fix this correctly
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import cx from 'classnames';
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import Spinner from 'components/Spinner';
|
||||
import TimePreference from 'components/TimePreferenceDropDown';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -86,6 +88,7 @@ function FullView({
|
||||
|
||||
const fullViewRef = useRef<HTMLDivElement>(null);
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentGraphRef(fullViewRef);
|
||||
@@ -203,8 +206,8 @@ function FullView({
|
||||
});
|
||||
}, [selectedPanelType]);
|
||||
|
||||
const response = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
|
||||
queryKey: [
|
||||
const queryRangeKey = useMemo(
|
||||
() => [
|
||||
widget?.query,
|
||||
selectedPanelType,
|
||||
requestData,
|
||||
@@ -212,10 +215,28 @@ function FullView({
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
[widget?.query, selectedPanelType, requestData, version, minTime, maxTime],
|
||||
);
|
||||
|
||||
const response = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
|
||||
queryKey: queryRangeKey,
|
||||
enabled: !isDependedDataLoaded,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (response.isFetching) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [response.isFetching]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries(queryRangeKey);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient, queryRangeKey]);
|
||||
|
||||
const onDragSelect = useCallback((start: number, end: number): void => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
@@ -354,6 +375,8 @@ function FullView({
|
||||
onStageRunQuery={(): void => {
|
||||
handleRunQuery();
|
||||
}}
|
||||
isLoadingQueries={response.isFetching}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -386,23 +409,27 @@ function FullView({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PanelWrapper
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
queryResponse={response}
|
||||
widget={widget}
|
||||
setRequestData={setRequestData}
|
||||
isFullViewMode
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphVisibility={setGraphsVisibilityStates}
|
||||
graphVisibility={graphsVisibilityStates}
|
||||
onDragSelect={customOnDragSelect ?? onDragSelect}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
searchTerm={searchTerm}
|
||||
onClickHandler={onClickHandler}
|
||||
enableDrillDown={enableDrillDown}
|
||||
selectedGraph={selectedPanelType}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>
|
||||
{isCancelled ? (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to reload the widget.' />
|
||||
) : (
|
||||
<PanelWrapper
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
queryResponse={response}
|
||||
widget={widget}
|
||||
setRequestData={setRequestData}
|
||||
isFullViewMode
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphVisibility={setGraphsVisibilityStates}
|
||||
graphVisibility={graphsVisibilityStates}
|
||||
onDragSelect={customOnDragSelect ?? onDragSelect}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
searchTerm={searchTerm}
|
||||
onClickHandler={onClickHandler}
|
||||
enableDrillDown={enableDrillDown}
|
||||
selectedGraph={selectedPanelType}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>
|
||||
)}
|
||||
</GraphContainer>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -7,6 +8,7 @@ import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
@@ -37,6 +39,20 @@ function Explorer(): JSX.Element {
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries([REACT_QUERY_KEY.GET_QUERY_RANGE]);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient]);
|
||||
|
||||
const [showQuickFilters, setShowQuickFilters] = useState(true);
|
||||
|
||||
@@ -155,7 +171,11 @@ function Explorer(): JSX.Element {
|
||||
|
||||
<div className="explore-header-right-actions">
|
||||
<DateTimeSelector showAutoRefresh />
|
||||
<RightToolbarActions onStageRunQuery={(): void => handleRunQuery()} />
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<QueryBuilderV2
|
||||
@@ -171,7 +191,10 @@ function Explorer(): JSX.Element {
|
||||
/>
|
||||
|
||||
<div className="explore-content">
|
||||
<TimeSeries />
|
||||
<TimeSeries
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
isCancelled={isCancelled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ExplorerOptionWrapper
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Button } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QueryBuilder } from 'container/QueryBuilder';
|
||||
import { ButtonWrapper } from 'container/TracesExplorer/QuerySection/styles';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { MeterExplorerEventKeys, MeterExplorerEvents } from '../events';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
return (
|
||||
<div className="query-section">
|
||||
<QueryBuilder
|
||||
panelType={panelTypes}
|
||||
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
|
||||
version="v4"
|
||||
actions={
|
||||
<ButtonWrapper>
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
handleRunQuery();
|
||||
logEvent(MeterExplorerEvents.QueryBuilderQueryChanged, {
|
||||
[MeterExplorerEventKeys.Tab]: 'explorer',
|
||||
});
|
||||
}}
|
||||
type="primary"
|
||||
>
|
||||
Run Query
|
||||
</Button>
|
||||
</ButtonWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuerySection;
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { isAxiosError } from 'axios';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
|
||||
@@ -21,7 +23,15 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
function TimeSeries(): JSX.Element {
|
||||
interface TimeSeriesProps {
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
isCancelled?: boolean;
|
||||
}
|
||||
|
||||
function TimeSeries({
|
||||
onFetchingStateChange,
|
||||
isCancelled = false,
|
||||
}: TimeSeriesProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery } = useQueryBuilder();
|
||||
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
|
||||
|
||||
@@ -67,7 +77,11 @@ function TimeSeries(): JSX.Element {
|
||||
minTime,
|
||||
index,
|
||||
],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
queryFn: ({
|
||||
signal,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(
|
||||
{
|
||||
query: payload,
|
||||
@@ -79,9 +93,15 @@ function TimeSeries(): JSX.Element {
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
undefined,
|
||||
signal,
|
||||
),
|
||||
enabled: !!payload,
|
||||
retry: (failureCount: number, error: Error): boolean => {
|
||||
retry: (failureCount: number, error: unknown): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let status: number | undefined;
|
||||
|
||||
if (error instanceof APIError) {
|
||||
@@ -94,7 +114,7 @@ function TimeSeries(): JSX.Element {
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < 3;
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
},
|
||||
onError: (error: APIError): void => {
|
||||
showErrorModal(error);
|
||||
@@ -102,6 +122,11 @@ function TimeSeries(): JSX.Element {
|
||||
})),
|
||||
);
|
||||
|
||||
const isFetching = queries.some((q) => q.isFetching);
|
||||
useEffect(() => {
|
||||
onFetchingStateChange?.(isFetching);
|
||||
}, [isFetching, onFetchingStateChange]);
|
||||
|
||||
const data = useMemo(() => queries.map(({ data }) => data) ?? [], [queries]);
|
||||
|
||||
const responseData = useMemo(
|
||||
@@ -122,7 +147,11 @@ function TimeSeries(): JSX.Element {
|
||||
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
|
||||
<div className="time-series-container">
|
||||
{!hasMetricSelected && <EmptyMetricsSearch />}
|
||||
{hasMetricSelected &&
|
||||
{isCancelled && hasMetricSelected && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load metrics.' />
|
||||
)}
|
||||
{!isCancelled &&
|
||||
hasMetricSelected &&
|
||||
responseData.map((datapoint, index) => (
|
||||
<div
|
||||
className="time-series-view-panel"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Switch, Tooltip } from 'antd';
|
||||
@@ -6,6 +7,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
@@ -54,6 +56,21 @@ function Explorer(): JSX.Element {
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries([REACT_QUERY_KEY.GET_QUERY_RANGE]);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient]);
|
||||
|
||||
const metricNames = useMemo(() => {
|
||||
const currentMetricNames: string[] = [];
|
||||
stagedQuery?.builder.queryData.forEach((query) => {
|
||||
@@ -307,7 +324,11 @@ function Explorer(): JSX.Element {
|
||||
<div className="explore-header-right-actions">
|
||||
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
|
||||
<DateTimeSelector showAutoRefresh />
|
||||
<RightToolbarActions onStageRunQuery={(): void => handleRunQuery()} />
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<QueryBuilderV2
|
||||
@@ -319,6 +340,7 @@ function Explorer(): JSX.Element {
|
||||
/>
|
||||
<div className="explore-content">
|
||||
<TimeSeries
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
showOneChartPerQuery={showOneChartPerQuery}
|
||||
setWarning={setWarning}
|
||||
areAllMetricUnitsSame={areAllMetricUnitsSame}
|
||||
@@ -331,6 +353,7 @@ function Explorer(): JSX.Element {
|
||||
yAxisUnit={yAxisUnit}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
showYAxisUnitSelector={showYAxisUnitSelector}
|
||||
isCancelled={isCancelled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Button } from 'antd';
|
||||
import { useCallback } from 'react';
|
||||
import { useIsFetching, useQueryClient } from 'react-query';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { QueryBuilder } from 'container/QueryBuilder';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { ButtonWrapper } from 'container/TracesExplorer/QuerySection/styles';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -11,9 +14,16 @@ import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
const isLoadingQueries = useIsFetching([REACT_QUERY_KEY.GET_QUERY_RANGE]) > 0;
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries([REACT_QUERY_KEY.GET_QUERY_RANGE]);
|
||||
}, [queryClient]);
|
||||
|
||||
return (
|
||||
<div className="query-section">
|
||||
<QueryBuilder
|
||||
@@ -22,17 +32,16 @@ function QuerySection(): JSX.Element {
|
||||
version="v4"
|
||||
actions={
|
||||
<ButtonWrapper>
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={(): void => {
|
||||
handleRunQuery();
|
||||
logEvent(MetricsExplorerEvents.QueryBuilderQueryChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'explorer',
|
||||
});
|
||||
}}
|
||||
type="primary"
|
||||
>
|
||||
Run Query
|
||||
</Button>
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
</ButtonWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQueries, useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
} from 'api/generated/services/metrics';
|
||||
import { isAxiosError } from 'axios';
|
||||
import classNames from 'classnames';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
@@ -36,6 +38,7 @@ import {
|
||||
} from './utils';
|
||||
|
||||
function TimeSeries({
|
||||
onFetchingStateChange,
|
||||
showOneChartPerQuery,
|
||||
setWarning,
|
||||
isMetricUnitsLoading,
|
||||
@@ -46,6 +49,7 @@ function TimeSeries({
|
||||
setYAxisUnit,
|
||||
showYAxisUnitSelector,
|
||||
metrics,
|
||||
isCancelled = false,
|
||||
}: TimeSeriesProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery } = useQueryBuilder();
|
||||
|
||||
@@ -98,7 +102,11 @@ function TimeSeries({
|
||||
minTime,
|
||||
index,
|
||||
],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
queryFn: ({
|
||||
signal,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(
|
||||
{
|
||||
query: payload,
|
||||
@@ -111,9 +119,15 @@ function TimeSeries({
|
||||
},
|
||||
// ENTITY_VERSION_V4,
|
||||
ENTITY_VERSION_V5,
|
||||
undefined,
|
||||
signal,
|
||||
),
|
||||
enabled: !!payload,
|
||||
retry: (failureCount: number, error: Error): boolean => {
|
||||
retry: (failureCount: number, error: unknown): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let status: number | undefined;
|
||||
|
||||
if (error instanceof APIError) {
|
||||
@@ -126,11 +140,16 @@ function TimeSeries({
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < 3;
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
const isFetching = queries.some((q) => q.isFetching);
|
||||
useEffect(() => {
|
||||
onFetchingStateChange?.(isFetching);
|
||||
}, [isFetching, onFetchingStateChange]);
|
||||
|
||||
const data = useMemo(() => queries.map(({ data }) => data) ?? [], [queries]);
|
||||
|
||||
const responseData = useMemo(
|
||||
@@ -231,7 +250,11 @@ function TimeSeries({
|
||||
})}
|
||||
>
|
||||
{metricNames.length === 0 && <EmptyMetricsSearch />}
|
||||
{metricNames.length > 0 &&
|
||||
{isCancelled && metricNames.length > 0 && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load metrics.' />
|
||||
)}
|
||||
{!isCancelled &&
|
||||
metricNames.length > 0 &&
|
||||
responseData.map((datapoint, index) => {
|
||||
const isQueryDataItem = index < metricNames.length;
|
||||
const metricName = isQueryDataItem ? metricNames[index] : undefined;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/si
|
||||
import { Warning } from 'types/api';
|
||||
|
||||
export interface TimeSeriesProps {
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
showOneChartPerQuery: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
areAllMetricUnitsSame: boolean;
|
||||
@@ -15,4 +16,5 @@ export interface TimeSeriesProps {
|
||||
yAxisUnit: string | undefined;
|
||||
setYAxisUnit: (unit: string) => void;
|
||||
showYAxisUnitSelector: boolean;
|
||||
isCancelled?: boolean;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,25 @@
|
||||
|
||||
.inspect-metrics-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
height: 100%;
|
||||
|
||||
.inspect-metrics-fallback-header-placeholder {
|
||||
// Reserve the same vertical space the GraphView header occupies
|
||||
// (antd middle button height) so swapping chart ↔ fallback causes
|
||||
// no layout shift.
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspect-metrics-fallback-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 520px;
|
||||
}
|
||||
}
|
||||
|
||||
.inspect-metrics-title {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Drawer, Empty, Skeleton, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricMetadata } from 'api/generated/services/metrics';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -109,6 +112,28 @@ function Inspect({
|
||||
reset,
|
||||
} = useInspectMetrics(appliedMetricName);
|
||||
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
// Auto-reset isCancelled only on the rising edge of a new fetch
|
||||
// (transition from not-loading → loading). Watching `isLoading` directly
|
||||
// races with the cancel flow — when the user cancels mid-fetch, loading
|
||||
// is still true in the render right after setIsCancelled(true), which
|
||||
// would immediately reset it.
|
||||
const wasLoadingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const nowLoading = isInspectMetricsLoading || isInspectMetricsRefetching;
|
||||
if (!wasLoadingRef.current && nowLoading) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
wasLoadingRef.current = nowLoading;
|
||||
}, [isInspectMetricsLoading, isInspectMetricsRefetching]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const handleCancelInspectQuery = useCallback(() => {
|
||||
queryClient.cancelQueries(REACT_QUERY_KEY.GET_INSPECT_METRICS_DETAILS);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient]);
|
||||
|
||||
const handleDispatchMetricInspectionOptions = useCallback(
|
||||
(action: MetricInspectionAction): void => {
|
||||
dispatchMetricInspectionOptions(action);
|
||||
@@ -167,96 +192,66 @@ function Inspect({
|
||||
setExpandedViewOptions(null);
|
||||
}, [inspectionStep]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (isInspectMetricsLoading && !isInspectMetricsRefetching) {
|
||||
return (
|
||||
<div
|
||||
data-testid="inspect-metrics-loading"
|
||||
className="inspect-metrics-fallback"
|
||||
>
|
||||
<Skeleton active />
|
||||
</div>
|
||||
const chartArea = useMemo(() => {
|
||||
const renderFallback = (testId: string, body: JSX.Element): JSX.Element => (
|
||||
<div data-testid={testId} className="inspect-metrics-fallback">
|
||||
<div className="inspect-metrics-fallback-header-placeholder" />
|
||||
<div className="inspect-metrics-fallback-body">{body}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Cancelled state takes precedence over any react-query state — ensures
|
||||
// the placeholder shows immediately on cancel, regardless of whether
|
||||
// isLoading/isRefetching has settled yet.
|
||||
if (isCancelled) {
|
||||
return renderFallback(
|
||||
'inspect-metrics-cancelled',
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to see inspect results.' />,
|
||||
);
|
||||
}
|
||||
|
||||
if (isInspectMetricsError) {
|
||||
const errorMessage = 'Error loading inspect metrics.';
|
||||
if (isInspectMetricsLoading && !isInspectMetricsRefetching) {
|
||||
return renderFallback('inspect-metrics-loading', <Skeleton active />);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="inspect-metrics-error"
|
||||
className="inspect-metrics-fallback"
|
||||
>
|
||||
<Empty description={errorMessage} />
|
||||
</div>
|
||||
if (isInspectMetricsError) {
|
||||
return renderFallback(
|
||||
'inspect-metrics-error',
|
||||
<Empty description="Error loading inspect metrics." />,
|
||||
);
|
||||
}
|
||||
|
||||
if (!inspectMetricsTimeSeries.length) {
|
||||
return (
|
||||
<div
|
||||
data-testid="inspect-metrics-empty"
|
||||
className="inspect-metrics-fallback"
|
||||
>
|
||||
<Empty description="No time series found for this metric to inspect." />
|
||||
</div>
|
||||
return renderFallback(
|
||||
'inspect-metrics-empty',
|
||||
<Empty description="No time series found for this metric to inspect." />,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inspect-metrics-content">
|
||||
<div className="inspect-metrics-content-first-col">
|
||||
<GraphView
|
||||
inspectMetricsTimeSeries={aggregatedTimeSeries}
|
||||
formattedInspectMetricsTimeSeries={formattedInspectMetricsTimeSeries}
|
||||
resetInspection={resetInspection}
|
||||
metricName={appliedMetricName}
|
||||
metricUnit={selectedMetricUnit}
|
||||
metricType={selectedMetricType}
|
||||
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
|
||||
inspectionStep={inspectionStep}
|
||||
setPopoverOptions={setPopoverOptions}
|
||||
setShowExpandedView={setShowExpandedView}
|
||||
showExpandedView={showExpandedView}
|
||||
setExpandedViewOptions={setExpandedViewOptions}
|
||||
popoverOptions={popoverOptions}
|
||||
metricInspectionAppliedOptions={metricInspectionOptions.appliedOptions}
|
||||
isInspectMetricsRefetching={isInspectMetricsRefetching}
|
||||
/>
|
||||
<QueryBuilder
|
||||
currentMetricName={currentMetricName}
|
||||
setCurrentMetricName={setCurrentMetricName}
|
||||
setAppliedMetricName={setAppliedMetricName}
|
||||
spaceAggregationLabels={spaceAggregationLabels}
|
||||
currentMetricInspectionOptions={metricInspectionOptions.currentOptions}
|
||||
dispatchMetricInspectionOptions={handleDispatchMetricInspectionOptions}
|
||||
inspectionStep={inspectionStep}
|
||||
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
|
||||
currentQuery={currentQueryData}
|
||||
setCurrentQuery={setCurrentQueryData}
|
||||
/>
|
||||
</div>
|
||||
<div className="inspect-metrics-content-second-col">
|
||||
<Stepper
|
||||
inspectionStep={inspectionStep}
|
||||
resetInspection={resetInspection}
|
||||
/>
|
||||
{showExpandedView && (
|
||||
<ExpandedView
|
||||
options={expandedViewOptions}
|
||||
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
|
||||
step={inspectionStep}
|
||||
metricInspectionAppliedOptions={metricInspectionOptions.appliedOptions}
|
||||
timeAggregatedSeriesMap={timeAggregatedSeriesMap}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GraphView
|
||||
inspectMetricsTimeSeries={aggregatedTimeSeries}
|
||||
formattedInspectMetricsTimeSeries={formattedInspectMetricsTimeSeries}
|
||||
resetInspection={resetInspection}
|
||||
metricName={appliedMetricName}
|
||||
metricUnit={selectedMetricUnit}
|
||||
metricType={selectedMetricType}
|
||||
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
|
||||
inspectionStep={inspectionStep}
|
||||
setPopoverOptions={setPopoverOptions}
|
||||
setShowExpandedView={setShowExpandedView}
|
||||
showExpandedView={showExpandedView}
|
||||
setExpandedViewOptions={setExpandedViewOptions}
|
||||
popoverOptions={popoverOptions}
|
||||
metricInspectionAppliedOptions={metricInspectionOptions.appliedOptions}
|
||||
isInspectMetricsRefetching={isInspectMetricsRefetching}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
isInspectMetricsLoading,
|
||||
isInspectMetricsRefetching,
|
||||
isInspectMetricsError,
|
||||
isCancelled,
|
||||
inspectMetricsTimeSeries,
|
||||
aggregatedTimeSeries,
|
||||
formattedInspectMetricsTimeSeries,
|
||||
@@ -312,7 +307,46 @@ function Inspect({
|
||||
className="inspect-metrics-modal"
|
||||
destroyOnClose
|
||||
>
|
||||
{content}
|
||||
<div className="inspect-metrics-content">
|
||||
<div className="inspect-metrics-content-first-col">
|
||||
{chartArea}
|
||||
<QueryBuilder
|
||||
currentMetricName={currentMetricName}
|
||||
setCurrentMetricName={setCurrentMetricName}
|
||||
setAppliedMetricName={setAppliedMetricName}
|
||||
spaceAggregationLabels={spaceAggregationLabels}
|
||||
currentMetricInspectionOptions={metricInspectionOptions.currentOptions}
|
||||
dispatchMetricInspectionOptions={handleDispatchMetricInspectionOptions}
|
||||
inspectionStep={inspectionStep}
|
||||
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
|
||||
currentQuery={currentQueryData}
|
||||
setCurrentQuery={setCurrentQueryData}
|
||||
isLoadingQueries={isInspectMetricsLoading || isInspectMetricsRefetching}
|
||||
handleCancelQuery={handleCancelInspectQuery}
|
||||
onRunQuery={(): void => {
|
||||
setIsCancelled(false);
|
||||
queryClient.invalidateQueries([
|
||||
REACT_QUERY_KEY.GET_INSPECT_METRICS_DETAILS,
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="inspect-metrics-content-second-col">
|
||||
<Stepper
|
||||
inspectionStep={inspectionStep}
|
||||
resetInspection={resetInspection}
|
||||
/>
|
||||
{showExpandedView && (
|
||||
<ExpandedView
|
||||
options={expandedViewOptions}
|
||||
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
|
||||
step={inspectionStep}
|
||||
metricInspectionAppliedOptions={metricInspectionOptions.appliedOptions}
|
||||
timeAggregatedSeriesMap={timeAggregatedSeriesMap}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -20,13 +20,22 @@ function QueryBuilder({
|
||||
inspectMetricsTimeSeries,
|
||||
currentQuery,
|
||||
setCurrentQuery,
|
||||
isLoadingQueries,
|
||||
handleCancelQuery,
|
||||
onRunQuery,
|
||||
}: QueryBuilderProps): JSX.Element {
|
||||
const applyInspectionOptions = useCallback(() => {
|
||||
onRunQuery?.();
|
||||
setAppliedMetricName(currentMetricName ?? '');
|
||||
dispatchMetricInspectionOptions({
|
||||
type: 'APPLY_METRIC_INSPECTION_OPTIONS',
|
||||
});
|
||||
}, [currentMetricName, setAppliedMetricName, dispatchMetricInspectionOptions]);
|
||||
}, [
|
||||
currentMetricName,
|
||||
setAppliedMetricName,
|
||||
dispatchMetricInspectionOptions,
|
||||
onRunQuery,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="inspect-metrics-query-builder">
|
||||
@@ -39,7 +48,11 @@ function QueryBuilder({
|
||||
>
|
||||
Query Builder
|
||||
</Button>
|
||||
<RunQueryBtn onStageRunQuery={applyInspectionOptions} />
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={applyInspectionOptions}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
/>
|
||||
</div>
|
||||
<Card className="inspect-metrics-query-builder-content">
|
||||
<MetricNameSearch
|
||||
|
||||
@@ -103,6 +103,8 @@ describe('QueryBuilder', () => {
|
||||
filterExpression: '',
|
||||
} as any,
|
||||
setCurrentQuery: jest.fn(),
|
||||
isLoadingQueries: false,
|
||||
handleCancelQuery: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -65,6 +65,9 @@ export interface QueryBuilderProps {
|
||||
inspectMetricsTimeSeries: InspectMetricsSeries[];
|
||||
currentQuery: IBuilderQuery;
|
||||
setCurrentQuery: (query: IBuilderQuery) => void;
|
||||
isLoadingQueries: boolean;
|
||||
handleCancelQuery: () => void;
|
||||
onRunQuery?: () => void;
|
||||
}
|
||||
|
||||
export interface MetricNameSearchProps {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { inspectMetrics } from 'api/generated/services/metrics';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
@@ -107,7 +110,7 @@ export function useInspectMetrics(
|
||||
isRefetching: isInspectMetricsRefetching,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
'inspectMetrics',
|
||||
REACT_QUERY_KEY.GET_INSPECT_METRICS_DETAILS,
|
||||
metricName,
|
||||
start,
|
||||
end,
|
||||
@@ -127,6 +130,12 @@ export function useInspectMetrics(
|
||||
),
|
||||
enabled: !!metricName,
|
||||
keepPreviousData: true,
|
||||
retry: (failureCount: number, error: Error): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
},
|
||||
});
|
||||
|
||||
const inspectMetricsData = useMemo(
|
||||
|
||||
@@ -12,6 +12,8 @@ function MetricsSearch({
|
||||
currentQueryFilterExpression,
|
||||
setCurrentQueryFilterExpression,
|
||||
isLoading,
|
||||
handleCancelQuery,
|
||||
onRunQuery,
|
||||
}: MetricsSearchProps): JSX.Element {
|
||||
const handleOnChange = useCallback(
|
||||
(expression: string): void => {
|
||||
@@ -22,7 +24,8 @@ function MetricsSearch({
|
||||
|
||||
const handleStageAndRunQuery = useCallback(() => {
|
||||
onChange(currentQueryFilterExpression);
|
||||
}, [currentQueryFilterExpression, onChange]);
|
||||
onRunQuery?.();
|
||||
}, [currentQueryFilterExpression, onChange, onRunQuery]);
|
||||
|
||||
const handleRunQuery = useCallback(
|
||||
(expression: string): void => {
|
||||
@@ -53,6 +56,7 @@ function MetricsSearch({
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={handleStageAndRunQuery}
|
||||
isLoadingQueries={isLoading}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
<div className="metrics-search-options">
|
||||
<DateTimeSelectionV2
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux'; // old code, TODO: fix this correctly
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
Querybuildertypesv5OrderByDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import eyesEmojiUrl from 'assets/Images/eyesEmoji.svg';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
|
||||
@@ -104,6 +106,8 @@ function Summary(): JSX.Element {
|
||||
setCurrentQueryFilterExpression,
|
||||
] = useState<string>(appliedFilterExpression);
|
||||
|
||||
const [isCancelled, setIsCancelled] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQueryFilterExpression(appliedFilterExpression);
|
||||
}, [appliedFilterExpression]);
|
||||
@@ -164,6 +168,7 @@ function Summary(): JSX.Element {
|
||||
isLoading: isGetMetricsStatsLoading,
|
||||
isError: isGetMetricsStatsError,
|
||||
error: metricsStatsError,
|
||||
reset: resetMetricsStats,
|
||||
} = useGetMetricsStats();
|
||||
|
||||
const {
|
||||
@@ -172,6 +177,7 @@ function Summary(): JSX.Element {
|
||||
isLoading: isGetMetricsTreemapLoading,
|
||||
isError: isGetMetricsTreemapError,
|
||||
error: metricsTreemapError,
|
||||
reset: resetMetricsTreemap,
|
||||
} = useGetMetricsTreemap();
|
||||
|
||||
const metricsStatsApiError = useMemo(
|
||||
@@ -196,6 +202,40 @@ function Summary(): JSX.Element {
|
||||
});
|
||||
}, [metricsTreemapQuery, getMetricsTreemap]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
resetMetricsStats();
|
||||
resetMetricsTreemap();
|
||||
setCurrentQueryFilterExpression(appliedFilterExpression);
|
||||
setIsCancelled(true);
|
||||
}, [
|
||||
resetMetricsStats,
|
||||
resetMetricsTreemap,
|
||||
setCurrentQueryFilterExpression,
|
||||
appliedFilterExpression,
|
||||
]);
|
||||
|
||||
const handleRunQuery = useCallback(() => {
|
||||
setIsCancelled(false);
|
||||
getMetricsStats({
|
||||
data: {
|
||||
...metricsListQuery,
|
||||
filter: { expression: currentQueryFilterExpression },
|
||||
},
|
||||
});
|
||||
getMetricsTreemap({
|
||||
data: {
|
||||
...metricsTreemapQuery,
|
||||
filter: { expression: currentQueryFilterExpression },
|
||||
},
|
||||
});
|
||||
}, [
|
||||
getMetricsStats,
|
||||
getMetricsTreemap,
|
||||
metricsListQuery,
|
||||
metricsTreemapQuery,
|
||||
currentQueryFilterExpression,
|
||||
]);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(expression: string) => {
|
||||
const newFilters: TagFilter = {
|
||||
@@ -330,11 +370,19 @@ function Summary(): JSX.Element {
|
||||
!isGetMetricsTreemapLoading &&
|
||||
!isGetMetricsTreemapError;
|
||||
|
||||
const isLoadingQueries =
|
||||
isGetMetricsStatsLoading || isGetMetricsTreemapLoading;
|
||||
|
||||
const showFullScreenLoading =
|
||||
(isGetMetricsStatsLoading || isGetMetricsTreemapLoading) &&
|
||||
isLoadingQueries &&
|
||||
formattedMetricsData.length === 0 &&
|
||||
!treeMapData?.data[heatmapView]?.length;
|
||||
|
||||
const showNoMetrics =
|
||||
isMetricsListDataEmpty &&
|
||||
isMetricsTreeMapDataEmpty &&
|
||||
!appliedFilterExpression;
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="metrics-explorer-summary-tab">
|
||||
@@ -343,13 +391,26 @@ function Summary(): JSX.Element {
|
||||
onChange={handleFilterChange}
|
||||
currentQueryFilterExpression={currentQueryFilterExpression}
|
||||
setCurrentQueryFilterExpression={setCurrentQueryFilterExpression}
|
||||
isLoading={isGetMetricsStatsLoading || isGetMetricsTreemapLoading}
|
||||
isLoading={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
onRunQuery={handleRunQuery}
|
||||
/>
|
||||
{showFullScreenLoading ? (
|
||||
<MetricsLoading />
|
||||
) : isMetricsListDataEmpty &&
|
||||
isMetricsTreeMapDataEmpty &&
|
||||
!appliedFilterExpression ? (
|
||||
) : isCancelled ? (
|
||||
<div className="no-logs-container">
|
||||
<div className="no-logs-container-content">
|
||||
<img className="eyes-emoji" src={eyesEmojiUrl} alt="eyes emoji" />
|
||||
<Typography className="no-logs-text">
|
||||
Query cancelled.
|
||||
<span className="sub-text">
|
||||
{' '}
|
||||
Click "Run Query" to load metrics.
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
) : showNoMetrics ? (
|
||||
<NoLogs dataSource={DataSource.METRICS} />
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface MetricsSearchProps {
|
||||
currentQueryFilterExpression: string;
|
||||
setCurrentQueryFilterExpression: (expression: string) => void;
|
||||
isLoading: boolean;
|
||||
handleCancelQuery: () => void;
|
||||
onRunQuery: () => void;
|
||||
}
|
||||
|
||||
export interface MetricsTreemapProps {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Tabs, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -25,8 +24,8 @@ import PromQLQueryContainer from './QueryBuilder/promQL';
|
||||
import './QuerySection.styles.scss';
|
||||
function QuerySection({
|
||||
selectedGraph,
|
||||
queryRangeKey,
|
||||
isLoadingQueries,
|
||||
handleCancelQuery,
|
||||
selectedWidget,
|
||||
dashboardVersion,
|
||||
dashboardId,
|
||||
@@ -179,7 +178,7 @@ function QuerySection({
|
||||
label="Stage & Run Query"
|
||||
onStageRunQuery={handleRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
queryRangeKey={queryRangeKey}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
@@ -191,8 +190,8 @@ function QuerySection({
|
||||
|
||||
interface QueryProps {
|
||||
selectedGraph: PANEL_TYPES;
|
||||
queryRangeKey?: QueryKey;
|
||||
isLoadingQueries?: boolean;
|
||||
isLoadingQueries: boolean;
|
||||
handleCancelQuery: () => void;
|
||||
selectedWidget: Widgets;
|
||||
dashboardVersion?: string;
|
||||
dashboardId?: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { Card } from 'container/GridCardLayout/styles';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
@@ -22,6 +23,7 @@ function WidgetGraph({
|
||||
selectedWidget,
|
||||
isLoadingPanelData,
|
||||
enableDrillDown = false,
|
||||
isCancelled = false,
|
||||
}: WidgetGraphContainerProps): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
@@ -46,20 +48,24 @@ function WidgetGraph({
|
||||
</div>
|
||||
<DateTimeSelectionV2 showAutoRefresh={false} hideShareModal />
|
||||
</div>
|
||||
{queryResponse.error && (
|
||||
{!isCancelled && queryResponse.error && (
|
||||
<AlertIconContainer color="red" title={queryResponse.error.message}>
|
||||
<InfoCircleOutlined />
|
||||
</AlertIconContainer>
|
||||
)}
|
||||
|
||||
<WidgetGraphComponent
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
selectedGraph={selectedGraph}
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
selectedWidget={selectedWidget}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
{isCancelled ? (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to reload the chart.' />
|
||||
) : (
|
||||
<WidgetGraphComponent
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
selectedGraph={selectedGraph}
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
selectedWidget={selectedWidget}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useEffect } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
@@ -34,6 +34,7 @@ function LeftContainer({
|
||||
isNewPanel = false,
|
||||
}: WidgetGraphProps): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { selectedTime: globalSelectedInterval, minTime, maxTime } = useSelector<
|
||||
AppState,
|
||||
@@ -49,12 +50,25 @@ function LeftContainer({
|
||||
],
|
||||
[globalSelectedInterval, requestData, minTime, maxTime],
|
||||
);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries(queryRangeKey);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient, queryRangeKey]);
|
||||
|
||||
const queryResponse = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
|
||||
enabled: !!stagedQuery,
|
||||
queryKey: queryRangeKey,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (queryResponse.isFetching) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [queryResponse.isFetching]);
|
||||
|
||||
// Update parent component with query response for legend colors
|
||||
useEffect(() => {
|
||||
if (setQueryResponse) {
|
||||
@@ -71,12 +85,13 @@ function LeftContainer({
|
||||
selectedWidget={selectedWidget}
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
enableDrillDown={enableDrillDown}
|
||||
isCancelled={isCancelled}
|
||||
/>
|
||||
<QueryContainer className="query-section-left-container">
|
||||
<QuerySection
|
||||
selectedGraph={selectedGraph}
|
||||
queryRangeKey={queryRangeKey}
|
||||
isLoadingQueries={queryResponse.isFetching}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
selectedWidget={selectedWidget}
|
||||
dashboardVersion={ENTITY_VERSION_V5}
|
||||
dashboardId={dashboardData?.id}
|
||||
|
||||
@@ -50,4 +50,5 @@ export type WidgetGraphContainerProps = {
|
||||
selectedWidget: Widgets;
|
||||
isLoadingPanelData: boolean;
|
||||
enableDrillDown?: boolean;
|
||||
isCancelled?: boolean;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useCallback } from 'react';
|
||||
import { QueryKey, useIsFetching, useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
@@ -12,14 +10,23 @@ import {
|
||||
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
|
||||
|
||||
import './RunQueryBtn.scss';
|
||||
interface RunQueryBtnProps {
|
||||
|
||||
type RunQueryBtnProps = {
|
||||
className?: string;
|
||||
label?: string;
|
||||
isLoadingQueries?: boolean;
|
||||
handleCancelQuery?: () => void;
|
||||
onStageRunQuery?: () => void;
|
||||
queryRangeKey?: QueryKey;
|
||||
}
|
||||
disabled?: boolean;
|
||||
} & (
|
||||
| {
|
||||
onStageRunQuery: () => void;
|
||||
handleCancelQuery: () => void;
|
||||
isLoadingQueries: boolean;
|
||||
}
|
||||
| {
|
||||
onStageRunQuery?: never;
|
||||
handleCancelQuery?: never;
|
||||
isLoadingQueries?: never;
|
||||
}
|
||||
);
|
||||
|
||||
function RunQueryBtn({
|
||||
className,
|
||||
@@ -27,26 +34,10 @@ function RunQueryBtn({
|
||||
isLoadingQueries,
|
||||
handleCancelQuery,
|
||||
onStageRunQuery,
|
||||
queryRangeKey,
|
||||
disabled,
|
||||
}: RunQueryBtnProps): JSX.Element {
|
||||
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
|
||||
const queryClient = useQueryClient();
|
||||
const isKeyFetchingCount = useIsFetching(
|
||||
queryRangeKey as QueryKey | undefined,
|
||||
);
|
||||
const isLoading =
|
||||
typeof isLoadingQueries === 'boolean'
|
||||
? isLoadingQueries
|
||||
: isKeyFetchingCount > 0;
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
if (handleCancelQuery) {
|
||||
return handleCancelQuery();
|
||||
}
|
||||
if (queryRangeKey) {
|
||||
queryClient.cancelQueries(queryRangeKey);
|
||||
}
|
||||
}, [handleCancelQuery, queryClient, queryRangeKey]);
|
||||
const isLoading = isLoadingQueries ?? false;
|
||||
|
||||
return isLoading ? (
|
||||
<Button
|
||||
@@ -54,7 +45,7 @@ function RunQueryBtn({
|
||||
type="button"
|
||||
prefix={<Loader2 size={14} className="loading-icon animate-spin" />}
|
||||
className={cx('cancel-query-btn', className)}
|
||||
onClick={onCancel}
|
||||
onClick={handleCancelQuery}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -63,7 +54,7 @@ function RunQueryBtn({
|
||||
color="primary"
|
||||
type="button"
|
||||
className={cx('run-query-btn', className)}
|
||||
disabled={isLoading || !onStageRunQuery}
|
||||
disabled={disabled}
|
||||
onClick={onStageRunQuery}
|
||||
prefix={<Play size={14} />}
|
||||
>
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
// frontend/src/container/QueryBuilder/components/RunQueryBtn/__tests__/RunQueryBtn.test.tsx
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import RunQueryBtn from '../RunQueryBtn';
|
||||
|
||||
jest.mock('react-query', () => {
|
||||
const actual = jest.requireActual('react-query');
|
||||
return {
|
||||
...actual,
|
||||
useIsFetching: jest.fn(),
|
||||
useQueryClient: jest.fn(),
|
||||
};
|
||||
});
|
||||
import { useIsFetching, useQueryClient } from 'react-query';
|
||||
|
||||
// Mock OS util
|
||||
jest.mock('utils/getUserOS', () => ({
|
||||
getUserOperatingSystem: jest.fn(),
|
||||
@@ -26,79 +16,60 @@ describe('RunQueryBtn', () => {
|
||||
(getUserOperatingSystem as jest.Mock).mockReturnValue(
|
||||
UserOperatingSystem.MACOS,
|
||||
);
|
||||
(useIsFetching as jest.Mock).mockReturnValue(0);
|
||||
(useQueryClient as jest.Mock).mockReturnValue({
|
||||
cancelQueries: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
test('uses isLoadingQueries prop over useIsFetching', () => {
|
||||
// Simulate fetching but prop forces not loading
|
||||
(useIsFetching as jest.Mock).mockReturnValue(1);
|
||||
test('renders run state and triggers on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRun = jest.fn();
|
||||
render(<RunQueryBtn onStageRunQuery={onRun} isLoadingQueries={false} />);
|
||||
// Should show "Run Query" (not cancel)
|
||||
const runBtn = screen.getByRole('button', { name: /run query/i });
|
||||
expect(runBtn).toBeInTheDocument();
|
||||
expect(runBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test('fallback cancel: uses handleCancelQuery when no key provided', () => {
|
||||
(useIsFetching as jest.Mock).mockReturnValue(0);
|
||||
const cancelQueries = jest.fn();
|
||||
(useQueryClient as jest.Mock).mockReturnValue({ cancelQueries });
|
||||
|
||||
const onCancel = jest.fn();
|
||||
render(<RunQueryBtn isLoadingQueries handleCancelQuery={onCancel} />);
|
||||
|
||||
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
|
||||
fireEvent.click(cancelBtn);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(cancelQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders run state and triggers on click', () => {
|
||||
const onRun = jest.fn();
|
||||
render(<RunQueryBtn onStageRunQuery={onRun} />);
|
||||
render(
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={onRun}
|
||||
handleCancelQuery={onCancel}
|
||||
isLoadingQueries={false}
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByRole('button', { name: /run query/i });
|
||||
expect(btn).toBeEnabled();
|
||||
fireEvent.click(btn);
|
||||
await user.click(btn);
|
||||
expect(onRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('disabled when onStageRunQuery is undefined', () => {
|
||||
render(<RunQueryBtn />);
|
||||
expect(screen.getByRole('button', { name: /run query/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('shows cancel state and calls handleCancelQuery', () => {
|
||||
test('shows cancel state and calls handleCancelQuery', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRun = jest.fn();
|
||||
const onCancel = jest.fn();
|
||||
render(<RunQueryBtn isLoadingQueries handleCancelQuery={onCancel} />);
|
||||
render(
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={onRun}
|
||||
handleCancelQuery={onCancel}
|
||||
isLoadingQueries
|
||||
/>,
|
||||
);
|
||||
const cancel = screen.getByRole('button', { name: /cancel/i });
|
||||
fireEvent.click(cancel);
|
||||
await user.click(cancel);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('derives loading from queryKey via useIsFetching and cancels via queryClient', () => {
|
||||
(useIsFetching as jest.Mock).mockReturnValue(1);
|
||||
const cancelQueries = jest.fn();
|
||||
(useQueryClient as jest.Mock).mockReturnValue({ cancelQueries });
|
||||
test('disabled when disabled prop is true', () => {
|
||||
render(<RunQueryBtn disabled />);
|
||||
expect(screen.getByRole('button', { name: /run query/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
const queryKey = ['GET_QUERY_RANGE', '1h', { some: 'req' }, 1, 2];
|
||||
render(<RunQueryBtn queryRangeKey={queryKey} />);
|
||||
|
||||
// Button switches to cancel state
|
||||
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
|
||||
expect(cancelBtn).toBeInTheDocument();
|
||||
|
||||
// Clicking cancel calls cancelQueries with the key
|
||||
fireEvent.click(cancelBtn);
|
||||
expect(cancelQueries).toHaveBeenCalledWith(queryKey);
|
||||
test('disabled when no props provided', () => {
|
||||
render(<RunQueryBtn />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /run query/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows Command + CornerDownLeft on mac', () => {
|
||||
const { container } = render(
|
||||
<RunQueryBtn onStageRunQuery={(): void => {}} />,
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={jest.fn()}
|
||||
handleCancelQuery={jest.fn()}
|
||||
isLoadingQueries={false}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('.lucide-command')).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -111,7 +82,11 @@ describe('RunQueryBtn', () => {
|
||||
UserOperatingSystem.WINDOWS,
|
||||
);
|
||||
const { container } = render(
|
||||
<RunQueryBtn onStageRunQuery={(): void => {}} />,
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={jest.fn()}
|
||||
handleCancelQuery={jest.fn()}
|
||||
isLoadingQueries={false}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('.lucide-chevron-up')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lucide-command')).not.toBeInTheDocument();
|
||||
@@ -121,8 +96,14 @@ describe('RunQueryBtn', () => {
|
||||
});
|
||||
|
||||
test('renders custom label when provided', () => {
|
||||
const onRun = jest.fn();
|
||||
render(<RunQueryBtn onStageRunQuery={onRun} label="Stage & Run Query" />);
|
||||
render(
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={jest.fn()}
|
||||
handleCancelQuery={jest.fn()}
|
||||
isLoadingQueries={false}
|
||||
label="Stage & Run Query"
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /stage & run query/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
|
||||
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
|
||||
@@ -9,23 +8,19 @@ import './ToolbarActions.styles.scss';
|
||||
|
||||
interface RightToolbarActionsProps {
|
||||
onStageRunQuery: () => void;
|
||||
isLoadingQueries?: boolean;
|
||||
listQueryKeyRef?: MutableRefObject<any>;
|
||||
chartQueryKeyRef?: MutableRefObject<any>;
|
||||
isLoadingQueries: boolean;
|
||||
handleCancelQuery: () => void;
|
||||
showLiveLogs?: boolean;
|
||||
}
|
||||
|
||||
export default function RightToolbarActions({
|
||||
onStageRunQuery,
|
||||
isLoadingQueries,
|
||||
listQueryKeyRef,
|
||||
chartQueryKeyRef,
|
||||
handleCancelQuery,
|
||||
showLiveLogs,
|
||||
}: RightToolbarActionsProps): JSX.Element {
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (showLiveLogs) {
|
||||
return;
|
||||
@@ -42,20 +37,11 @@ export default function RightToolbarActions({
|
||||
if (showLiveLogs) {
|
||||
return (
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RunQueryBtn />
|
||||
<RunQueryBtn disabled />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCancelQuery = (): void => {
|
||||
if (listQueryKeyRef?.current) {
|
||||
queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
if (chartQueryKeyRef?.current) {
|
||||
queryClient.cancelQueries(chartQueryKeyRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RunQueryBtn
|
||||
@@ -68,8 +54,5 @@ export default function RightToolbarActions({
|
||||
}
|
||||
|
||||
RightToolbarActions.defaultProps = {
|
||||
isLoadingQueries: false,
|
||||
listQueryKeyRef: null,
|
||||
chartQueryKeyRef: null,
|
||||
showLiveLogs: false,
|
||||
};
|
||||
|
||||
@@ -92,7 +92,12 @@ describe('ToolbarActions', () => {
|
||||
const onStageRunQuery = jest.fn();
|
||||
const { queryByText } = render(
|
||||
<MockQueryClientProvider>
|
||||
<RightToolbarActions onStageRunQuery={onStageRunQuery} />,
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
isLoadingQueries={false}
|
||||
handleCancelQuery={jest.fn()}
|
||||
/>
|
||||
,
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { updateBarStepInterval } from 'container/GridCardLayout/utils';
|
||||
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
|
||||
@@ -132,6 +133,10 @@ export const useGetQueryRange: UseGetQueryRange = (
|
||||
return options.retry;
|
||||
}
|
||||
return (failureCount: number, error: Error): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let status: number | undefined;
|
||||
|
||||
if (error instanceof APIError) {
|
||||
@@ -144,7 +149,7 @@ export const useGetQueryRange: UseGetQueryRange = (
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < 3;
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
};
|
||||
}, [options?.retry]);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import listOverview from 'api/thirdPartyApis/listOverview';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -20,12 +22,21 @@ export const useListOverview = (
|
||||
showIp,
|
||||
filter.expression,
|
||||
],
|
||||
queryFn: () =>
|
||||
listOverview({
|
||||
start,
|
||||
end,
|
||||
show_ip: showIp,
|
||||
filter,
|
||||
}),
|
||||
queryFn: ({ signal }) =>
|
||||
listOverview(
|
||||
{
|
||||
start,
|
||||
end,
|
||||
show_ip: showIp,
|
||||
filter,
|
||||
},
|
||||
signal,
|
||||
),
|
||||
retry: (failureCount, error): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,577 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { createMemoryHistory, MemoryHistory } from 'history';
|
||||
import { encode } from 'js-base64';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
|
||||
import ResourceProvider from '../ResourceProvider';
|
||||
import useResourceAttribute from '../useResourceAttribute';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
location: {
|
||||
search: '',
|
||||
pathname: '/',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('api/metrics/getResourceAttributes', () => ({
|
||||
getResourceAttributesTagKeys: jest.fn(),
|
||||
getResourceAttributesTagValues: jest.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first, import/order
|
||||
import {
|
||||
getResourceAttributesTagKeys,
|
||||
getResourceAttributesTagValues,
|
||||
// eslint-disable-next-line import/newline-after-import
|
||||
} from 'api/metrics/getResourceAttributes';
|
||||
// eslint-disable-next-line import/first, import/order
|
||||
import history from 'lib/history';
|
||||
|
||||
const mockTagKeys = getResourceAttributesTagKeys as jest.MockedFunction<
|
||||
typeof getResourceAttributesTagKeys
|
||||
>;
|
||||
const mockTagValues = getResourceAttributesTagValues as jest.MockedFunction<
|
||||
typeof getResourceAttributesTagValues
|
||||
>;
|
||||
|
||||
function createWrapper({
|
||||
routerHistory,
|
||||
appContextOverrides,
|
||||
}: {
|
||||
routerHistory: MemoryHistory;
|
||||
appContextOverrides?: Partial<IAppContext>;
|
||||
}): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContext.Provider value={getAppContextMock('ADMIN', appContextOverrides)}>
|
||||
<Router history={routerHistory}>
|
||||
<ResourceProvider>{children}</ResourceProvider>
|
||||
</Router>
|
||||
</AppContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function mockLibHistory(search = '', pathname = '/'): void {
|
||||
(history.location as { search: string; pathname: string }).search = search;
|
||||
(history.location as { search: string; pathname: string }).pathname = pathname;
|
||||
}
|
||||
|
||||
type TagKeysPayload = Parameters<typeof mockTagKeys.mockResolvedValue>[0];
|
||||
type TagValuesPayload = Parameters<typeof mockTagValues.mockResolvedValue>[0];
|
||||
|
||||
function successTagKeysPayload(keys: string[]): TagKeysPayload {
|
||||
return ({
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'ok',
|
||||
payload: {
|
||||
data: {
|
||||
attributeKeys: keys.map((key) => ({
|
||||
key,
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
isColumn: false,
|
||||
})),
|
||||
},
|
||||
},
|
||||
} as unknown) as TagKeysPayload;
|
||||
}
|
||||
|
||||
function successTagValuesPayload(values: string[]): TagValuesPayload {
|
||||
return ({
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'ok',
|
||||
payload: {
|
||||
data: {
|
||||
stringAttributeValues: values,
|
||||
},
|
||||
},
|
||||
} as unknown) as TagValuesPayload;
|
||||
}
|
||||
|
||||
describe('ResourceProvider', () => {
|
||||
beforeEach(() => {
|
||||
mockSafeNavigate.mockReset();
|
||||
mockTagKeys.mockReset();
|
||||
mockTagValues.mockReset();
|
||||
mockLibHistory('', '/');
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts loading with empty staging, selectedQuery, and queries', () => {
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.queries).toStrictEqual([]);
|
||||
expect(result.current.staging).toStrictEqual([]);
|
||||
expect(result.current.selectedQuery).toStrictEqual([]);
|
||||
expect(result.current.optionsData).toStrictEqual({ mode: undefined, options: [] });
|
||||
});
|
||||
|
||||
it('hydrates queries from the resourceAttribute URL param on mount', () => {
|
||||
const seeded = [
|
||||
{
|
||||
id: 'abc',
|
||||
tagKey: 'resource_service_name',
|
||||
operator: 'IN',
|
||||
tagValue: ['frontend'],
|
||||
},
|
||||
];
|
||||
mockLibHistory(`?resourceAttribute=${encode(JSON.stringify(seeded))}`, '/');
|
||||
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
expect(result.current.queries).toStrictEqual(seeded);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state-machine transitions via handleFocus / handleChange', () => {
|
||||
it('Idle → TagKey fetches tag keys and populates options', async () => {
|
||||
mockTagKeys.mockResolvedValue(successTagKeysPayload(['resource_service_name']));
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleFocus();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagKeys).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.optionsData.options).toStrictEqual([
|
||||
{ label: 'service.name', value: 'resource_service_name' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('TagKey → Operator sets OperatorSchema on handleChange (no mode)', async () => {
|
||||
mockTagKeys.mockResolvedValue(successTagKeysPayload(['resource_service_name']));
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleFocus();
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange('resource_service_name');
|
||||
});
|
||||
|
||||
expect(result.current.staging).toStrictEqual(['resource_service_name']);
|
||||
expect(result.current.optionsData.options).toStrictEqual([
|
||||
{ label: 'IN', value: 'IN' },
|
||||
{ label: 'Not IN', value: 'Not IN' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('Operator → TagValue fetches values using staging[0]', async () => {
|
||||
mockTagKeys.mockResolvedValue(successTagKeysPayload(['resource_service_name']));
|
||||
mockTagValues.mockResolvedValue(successTagValuesPayload(['frontend', 'backend']));
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleFocus();
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => {
|
||||
result.current.handleChange('resource_service_name');
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleChange('IN');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tagKey: 'resource_service_name' }),
|
||||
);
|
||||
expect(result.current.optionsData.mode).toBe('multiple');
|
||||
expect(result.current.optionsData.options).toStrictEqual([
|
||||
{ label: 'frontend', value: 'frontend' },
|
||||
{ label: 'backend', value: 'backend' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('handleChange with mode updates selectedQuery instead of staging', async () => {
|
||||
mockTagKeys.mockResolvedValue(successTagKeysPayload(['resource_service_name']));
|
||||
mockTagValues.mockResolvedValue(successTagValuesPayload(['frontend']));
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleFocus();
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => {
|
||||
result.current.handleChange('resource_service_name');
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleChange('IN');
|
||||
});
|
||||
await waitFor(() => expect(result.current.optionsData.mode).toBe('multiple'));
|
||||
|
||||
act(() => {
|
||||
// In multiple mode, handleChange treats value as iterable of selected values.
|
||||
result.current.handleChange(('frontend' as unknown) as string);
|
||||
});
|
||||
|
||||
expect(result.current.selectedQuery).toStrictEqual([
|
||||
'f',
|
||||
'r',
|
||||
'o',
|
||||
'n',
|
||||
't',
|
||||
'e',
|
||||
'n',
|
||||
'd',
|
||||
]);
|
||||
// Staging not advanced by mode-mode handleChange
|
||||
expect(result.current.staging).toStrictEqual(['resource_service_name', 'IN']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleBlur', () => {
|
||||
it('commits a query when TagValue staging is complete and selectedQuery non-empty', async () => {
|
||||
mockTagKeys.mockResolvedValue(successTagKeysPayload(['resource_service_name']));
|
||||
mockTagValues.mockResolvedValue(successTagValuesPayload(['frontend']));
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/svc'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleFocus();
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => result.current.handleChange('resource_service_name'));
|
||||
act(() => result.current.handleChange('IN'));
|
||||
await waitFor(() => expect(result.current.optionsData.mode).toBe('multiple'));
|
||||
act(() => {
|
||||
// Build selectedQuery = ['frontend']
|
||||
result.current.handleChange((['frontend'] as unknown) as string);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleBlur();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queries).toHaveLength(1);
|
||||
expect(result.current.queries[0]).toMatchObject({
|
||||
tagKey: 'resource_service_name',
|
||||
operator: 'IN',
|
||||
tagValue: ['frontend'],
|
||||
});
|
||||
expect(result.current.staging).toStrictEqual([]);
|
||||
expect(result.current.selectedQuery).toStrictEqual([]);
|
||||
expect(mockSafeNavigate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets state without committing when staging is incomplete', async () => {
|
||||
mockTagKeys.mockResolvedValue(successTagKeysPayload(['resource_service_name']));
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleFocus();
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => result.current.handleChange('resource_service_name'));
|
||||
|
||||
act(() => {
|
||||
result.current.handleBlur();
|
||||
});
|
||||
|
||||
expect(result.current.queries).toStrictEqual([]);
|
||||
expect(result.current.staging).toStrictEqual([]);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleClose / handleClearAll', () => {
|
||||
it('handleClose removes the matching query and navigates', async () => {
|
||||
const seeded = [
|
||||
{ id: 'a', tagKey: 'resource_a', operator: 'IN', tagValue: ['x'] },
|
||||
{ id: 'b', tagKey: 'resource_b', operator: 'IN', tagValue: ['y'] },
|
||||
];
|
||||
mockLibHistory(`?resourceAttribute=${encode(JSON.stringify(seeded))}`, '/');
|
||||
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
expect(result.current.queries).toHaveLength(2);
|
||||
|
||||
act(() => {
|
||||
result.current.handleClose('a');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queries).toStrictEqual([seeded[1]]);
|
||||
expect(mockSafeNavigate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('handleClearAll wipes queries, staging, selectedQuery, options', async () => {
|
||||
const seeded = [
|
||||
{ id: 'a', tagKey: 'resource_a', operator: 'IN', tagValue: ['x'] },
|
||||
];
|
||||
mockLibHistory(`?resourceAttribute=${encode(JSON.stringify(seeded))}`, '/');
|
||||
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleClearAll();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queries).toStrictEqual([]);
|
||||
expect(result.current.staging).toStrictEqual([]);
|
||||
expect(result.current.optionsData).toStrictEqual({
|
||||
mode: undefined,
|
||||
options: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleEnvironmentChange', () => {
|
||||
it('adds an environment query when envs are provided', async () => {
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleEnvironmentChange(['production']);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queries).toHaveLength(1);
|
||||
expect(result.current.queries[0]).toMatchObject({
|
||||
tagKey: 'resource_deployment_environment',
|
||||
operator: 'IN',
|
||||
tagValue: ['production'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the environment query when an empty array is passed', async () => {
|
||||
const seeded = [
|
||||
{
|
||||
id: 'env',
|
||||
tagKey: 'resource_deployment_environment',
|
||||
operator: 'IN',
|
||||
tagValue: ['production'],
|
||||
},
|
||||
{
|
||||
id: 'svc',
|
||||
tagKey: 'resource_service_name',
|
||||
operator: 'IN',
|
||||
tagValue: ['frontend'],
|
||||
},
|
||||
];
|
||||
mockLibHistory(`?resourceAttribute=${encode(JSON.stringify(seeded))}`, '/');
|
||||
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleEnvironmentChange([]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const tagKeys = result.current.queries.map((q) => q.tagKey);
|
||||
expect(tagKeys).not.toContain('resource_deployment_environment');
|
||||
expect(tagKeys).toContain('resource_service_name');
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces an existing environment query rather than appending', async () => {
|
||||
const seeded = [
|
||||
{
|
||||
id: 'env',
|
||||
tagKey: 'resource_deployment_environment',
|
||||
operator: 'IN',
|
||||
tagValue: ['production'],
|
||||
},
|
||||
];
|
||||
mockLibHistory(`?resourceAttribute=${encode(JSON.stringify(seeded))}`, '/');
|
||||
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleEnvironmentChange(['staging']);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const envQueries = result.current.queries.filter(
|
||||
(q) => q.tagKey === 'resource_deployment_environment',
|
||||
);
|
||||
expect(envQueries).toHaveLength(1);
|
||||
expect(envQueries[0].tagValue).toStrictEqual(['staging']);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the dotted deployment env key when DOT_METRICS_ENABLED is active', async () => {
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({
|
||||
routerHistory,
|
||||
appContextOverrides: {
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.DOT_METRICS_ENABLED,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleEnvironmentChange(['production']);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queries[0].tagKey).toBe(
|
||||
'resource_deployment.environment',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unrelated query params when dispatching', async () => {
|
||||
const routerHistory = createMemoryHistory({
|
||||
initialEntries: ['/?tab=overview'],
|
||||
});
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleEnvironmentChange(['production']);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSafeNavigate).toHaveBeenCalled();
|
||||
const calledWith = mockSafeNavigate.mock.calls[0][0] as string;
|
||||
expect(calledWith).toContain('tab=overview');
|
||||
expect(calledWith).toContain('resourceAttribute=');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVisibleQueries (SERVICE_MAP filtering)', () => {
|
||||
it('filters queries down to whitelisted keys on SERVICE_MAP', () => {
|
||||
const seeded = [
|
||||
{
|
||||
id: 'a',
|
||||
tagKey: 'resource_service_name',
|
||||
operator: 'IN',
|
||||
tagValue: ['frontend'],
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
tagKey: 'resource_k8s_cluster_name',
|
||||
operator: 'IN',
|
||||
tagValue: ['prod'],
|
||||
},
|
||||
];
|
||||
mockLibHistory(
|
||||
`?resourceAttribute=${encode(JSON.stringify(seeded))}`,
|
||||
ROUTES.SERVICE_MAP,
|
||||
);
|
||||
|
||||
const routerHistory = createMemoryHistory({
|
||||
initialEntries: [ROUTES.SERVICE_MAP],
|
||||
});
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
expect(result.current.queries).toStrictEqual([seeded[1]]);
|
||||
});
|
||||
|
||||
it('returns all queries on non-SERVICE_MAP routes', () => {
|
||||
const seeded = [
|
||||
{
|
||||
id: 'a',
|
||||
tagKey: 'resource_service_name',
|
||||
operator: 'IN',
|
||||
tagValue: ['frontend'],
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
tagKey: 'resource_k8s_cluster_name',
|
||||
operator: 'IN',
|
||||
tagValue: ['prod'],
|
||||
},
|
||||
];
|
||||
mockLibHistory(`?resourceAttribute=${encode(JSON.stringify(seeded))}`, '/services');
|
||||
|
||||
const routerHistory = createMemoryHistory({ initialEntries: ['/services'] });
|
||||
const { result } = renderHook(() => useResourceAttribute(), {
|
||||
wrapper: createWrapper({ routerHistory }),
|
||||
});
|
||||
|
||||
expect(result.current.queries).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,73 +1,22 @@
|
||||
.uplot-tooltip-container {
|
||||
.container {
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--bg-ink-300);
|
||||
background: var(--l2-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--bg-vanilla-100);
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--bg-ink-100);
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&.lightMode {
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--bg-ink-500);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.uplot-tooltip-list {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-divider {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
.uplot-tooltip-header-container {
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.uplot-tooltip-header {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-divider {
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
.uplot-tooltip-list {
|
||||
// Virtuoso absolutely positions its item rows; left: 0 prevents accidental
|
||||
// horizontal offset when the scroller has padding or transform applied.
|
||||
div[data-viewport-type='element'] {
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 12px 4px 16px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-100);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,28 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { TooltipProps } from '../types';
|
||||
import TooltipItem from './components/TooltipItem/TooltipItem';
|
||||
import TooltipFooter from './components/TooltipFooter/TooltipFooter';
|
||||
import TooltipHeader from './components/TooltipHeader/TooltipHeader';
|
||||
import TooltipList from './components/TooltipList/TooltipList';
|
||||
|
||||
import Styles from './Tooltip.module.scss';
|
||||
|
||||
// Fallback per-item height used for the initial size estimate before
|
||||
// Virtuoso reports the real total height via totalListHeightChanged.
|
||||
const TOOLTIP_ITEM_HEIGHT = 38;
|
||||
const LIST_MAX_HEIGHT = 300;
|
||||
|
||||
export default function Tooltip({
|
||||
uPlotInstance,
|
||||
timezone,
|
||||
content,
|
||||
showTooltipHeader = true,
|
||||
isPinned,
|
||||
canPinTooltip,
|
||||
dismiss,
|
||||
}: TooltipProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const [totalListHeight, setTotalListHeight] = useState(0);
|
||||
|
||||
const tooltipContent = useMemo(() => content ?? [], [content]);
|
||||
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!showTooltipHeader) {
|
||||
return null;
|
||||
}
|
||||
const cursorIdx = uPlotInstance.cursor.idx;
|
||||
if (cursorIdx == null) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = uPlotInstance.data[0]?.[cursorIdx];
|
||||
if (timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(timestamp * 1000)
|
||||
.tz(resolvedTimezone)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
}, [
|
||||
resolvedTimezone,
|
||||
uPlotInstance.data,
|
||||
uPlotInstance.cursor.idx,
|
||||
showTooltipHeader,
|
||||
]);
|
||||
|
||||
const activeItem = useMemo(
|
||||
() => tooltipContent.find((item) => item.isActive) ?? null,
|
||||
[tooltipContent],
|
||||
);
|
||||
|
||||
// Use the measured height from Virtuoso when available; fall back to a
|
||||
// per-item estimate on the first render. Math.ceil prevents a 1 px
|
||||
// subpixel rounding gap from triggering a spurious scrollbar.
|
||||
const virtuosoHeight = useMemo(() => {
|
||||
return totalListHeight > 0
|
||||
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
|
||||
: Math.min(tooltipContent.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT);
|
||||
}, [totalListHeight, tooltipContent.length]);
|
||||
|
||||
const showHeader = showTooltipHeader || activeItem != null;
|
||||
// With a single series the active item is fully represented in the header —
|
||||
// hide the divider and list to avoid showing a duplicate row.
|
||||
@@ -74,46 +31,24 @@ export default function Tooltip({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.uplotTooltipContainer, !isDarkMode && Styles.lightMode)}
|
||||
className={cx(Styles.container, isPinned && Styles.pinned)}
|
||||
data-testid="uplot-tooltip-container"
|
||||
>
|
||||
{showHeader && (
|
||||
<div className={Styles.uplotTooltipHeaderContainer}>
|
||||
{showTooltipHeader && headerTitle && (
|
||||
<div
|
||||
className={Styles.uplotTooltipHeader}
|
||||
data-testid="uplot-tooltip-header"
|
||||
>
|
||||
<span>{headerTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeItem && (
|
||||
<TooltipItem
|
||||
item={activeItem}
|
||||
isItemActive={true}
|
||||
containerTestId="uplot-tooltip-pinned"
|
||||
markerTestId="uplot-tooltip-pinned-marker"
|
||||
contentTestId="uplot-tooltip-pinned-content"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDivider && <span className={Styles.uplotTooltipDivider} />}
|
||||
|
||||
{showList && (
|
||||
<Virtuoso
|
||||
className={Styles.uplotTooltipList}
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={tooltipContent}
|
||||
style={{ height: virtuosoHeight, width: '100%' }}
|
||||
totalListHeightChanged={setTotalListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<TooltipItem item={item} isItemActive={false} />
|
||||
)}
|
||||
<TooltipHeader
|
||||
uPlotInstance={uPlotInstance}
|
||||
timezone={timezone}
|
||||
showTooltipHeader={showTooltipHeader}
|
||||
isPinned={isPinned}
|
||||
activeItem={activeItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDivider && <span className={Styles.divider} />}
|
||||
|
||||
{showList && <TooltipList content={tooltipContent} />}
|
||||
|
||||
{canPinTooltip && <TooltipFooter isPinned={isPinned} dismiss={dismiss} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -92,7 +93,6 @@ function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
|
||||
isPinned: false,
|
||||
dismiss: jest.fn(),
|
||||
viaSync: false,
|
||||
clickData: null,
|
||||
} as TooltipTestProps;
|
||||
|
||||
return render(
|
||||
@@ -191,3 +191,85 @@ describe('Tooltip', () => {
|
||||
expect(list).toHaveStyle({ height: '76px' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tooltip footer hint', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseIsDarkMode.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('renders footer with "Press P to pin the tooltip" hint when not pinned', () => {
|
||||
renderTooltip({ isPinned: false, canPinTooltip: true });
|
||||
|
||||
const footer = screen.getByTestId('uplot-tooltip-footer');
|
||||
expect(footer).toBeInTheDocument();
|
||||
expect(footer).toHaveTextContent('Press');
|
||||
expect(footer).toHaveTextContent('P');
|
||||
expect(footer).toHaveTextContent('to pin the tooltip');
|
||||
});
|
||||
|
||||
it('renders footer with "Press P or Esc to unpin" hint when pinned', () => {
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true });
|
||||
|
||||
const footer = screen.getByTestId('uplot-tooltip-footer');
|
||||
expect(footer).toHaveTextContent('Press');
|
||||
expect(footer).toHaveTextContent('P');
|
||||
expect(footer).toHaveTextContent('Esc');
|
||||
expect(footer).toHaveTextContent('to unpin');
|
||||
});
|
||||
|
||||
it('does not render Unpin button when not pinned', () => {
|
||||
renderTooltip({ isPinned: false, canPinTooltip: true });
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Unpin button when pinned', () => {
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true });
|
||||
|
||||
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
|
||||
expect(unpinBtn).toBeInTheDocument();
|
||||
expect(unpinBtn).toHaveAttribute('aria-label', 'Unpin tooltip');
|
||||
});
|
||||
|
||||
it('calls dismiss when Unpin button is clicked', async () => {
|
||||
const dismiss = jest.fn();
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true, dismiss });
|
||||
|
||||
const user = userEvent.setup();
|
||||
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
|
||||
await user.click(unpinBtn);
|
||||
|
||||
expect(dismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('footer has role="status" for screen reader announcements', () => {
|
||||
renderTooltip({ canPinTooltip: true });
|
||||
|
||||
const footer = screen.getByRole('status');
|
||||
expect(footer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tooltip header status pill', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseIsDarkMode.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('shows Pinned status when pinned and header is visible', () => {
|
||||
const uPlotInstance = createUPlotInstance(0);
|
||||
|
||||
renderTooltip({ uPlotInstance, isPinned: true });
|
||||
|
||||
expect(screen.getByText('Pinned')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render status pill when showTooltipHeader is false', () => {
|
||||
const uPlotInstance = createUPlotInstance(0);
|
||||
|
||||
renderTooltip({ uPlotInstance, showTooltipHeader: false, isPinned: false });
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 7px 12px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Kbd } from '@signozhq/ui';
|
||||
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import Styles from './TooltipFooter.module.scss';
|
||||
|
||||
interface TooltipFooterProps {
|
||||
pinKey?: string;
|
||||
isPinned: boolean;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
export default function TooltipFooter({
|
||||
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
|
||||
isPinned,
|
||||
dismiss,
|
||||
}: TooltipFooterProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={Styles.footer}
|
||||
role="status"
|
||||
data-testid="uplot-tooltip-footer"
|
||||
>
|
||||
<div className={Styles.hint}>
|
||||
{isPinned ? (
|
||||
<>
|
||||
<span>Press</span>
|
||||
<Kbd active>{pinKey.toUpperCase()}</Kbd>
|
||||
<span>or</span>
|
||||
<Kbd active>Esc</Kbd>
|
||||
<span>to unpin</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Press</span>
|
||||
<Kbd>{pinKey.toUpperCase()}</Kbd>
|
||||
<span>to pin the tooltip</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isPinned && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={dismiss}
|
||||
aria-label="Unpin tooltip"
|
||||
data-testid="uplot-tooltip-unpin"
|
||||
>
|
||||
<X size={10} />
|
||||
<span>Unpin</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.headerContainer {
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--callout-primary-title);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { Pin } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
import TooltipItem from '../TooltipItem/TooltipItem';
|
||||
|
||||
import Styles from './TooltipHeader.module.scss';
|
||||
|
||||
interface TooltipHeaderProps {
|
||||
uPlotInstance: uPlot;
|
||||
timezone?: Timezone;
|
||||
showTooltipHeader: boolean;
|
||||
isPinned: boolean;
|
||||
activeItem: TooltipContentItem | null;
|
||||
}
|
||||
|
||||
export default function TooltipHeader({
|
||||
uPlotInstance,
|
||||
timezone,
|
||||
showTooltipHeader,
|
||||
isPinned,
|
||||
activeItem,
|
||||
}: TooltipHeaderProps): JSX.Element {
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!showTooltipHeader) {
|
||||
return null;
|
||||
}
|
||||
const cursorIdx = uPlotInstance.cursor.idx;
|
||||
if (cursorIdx == null) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = uPlotInstance.data[0]?.[cursorIdx];
|
||||
if (timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(timestamp * 1000)
|
||||
.tz(resolvedTimezone)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
}, [
|
||||
resolvedTimezone,
|
||||
uPlotInstance.data,
|
||||
uPlotInstance.cursor.idx,
|
||||
showTooltipHeader,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={Styles.headerContainer}
|
||||
data-testid="uplot-tooltip-header-container"
|
||||
>
|
||||
{showTooltipHeader && headerTitle && (
|
||||
<div className={Styles.headerRow}>
|
||||
<span>{headerTitle}</span>
|
||||
{isPinned && (
|
||||
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
|
||||
<>
|
||||
<Pin size={12} />
|
||||
<span>Pinned</span>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeItem && (
|
||||
<TooltipItem
|
||||
item={activeItem}
|
||||
isItemActive={true}
|
||||
containerTestId="uplot-tooltip-pinned"
|
||||
markerTestId="uplot-tooltip-pinned-marker"
|
||||
contentTestId="uplot-tooltip-pinned-content"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.list {
|
||||
width: 100%;
|
||||
:global(div[data-viewport-type='element']) {
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 12px 4px 16px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-100);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.listLightMode {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import cx from 'classnames';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
import TooltipItem from '../TooltipItem/TooltipItem';
|
||||
|
||||
import Styles from './TooltipList.module.scss';
|
||||
|
||||
// Fallback per-item height before Virtuoso reports the real total.
|
||||
const TOOLTIP_ITEM_HEIGHT = 38;
|
||||
const LIST_MAX_HEIGHT = 300;
|
||||
|
||||
interface TooltipListProps {
|
||||
content: TooltipContentItem[];
|
||||
}
|
||||
|
||||
export default function TooltipList({
|
||||
content,
|
||||
}: TooltipListProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [totalListHeight, setTotalListHeight] = useState(0);
|
||||
|
||||
// Use the measured height from Virtuoso when available; fall back to a
|
||||
// per-item estimate on first render. Math.ceil prevents a 1 px
|
||||
// subpixel rounding gap from triggering a spurious scrollbar.
|
||||
const height = useMemo(
|
||||
() =>
|
||||
totalListHeight > 0
|
||||
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
|
||||
: Math.min(content.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT),
|
||||
[totalListHeight, content.length],
|
||||
);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={content}
|
||||
style={{ height }}
|
||||
totalListHeightChanged={setTotalListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<TooltipItem item={item} isItemActive={false} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export interface TooltipRenderArgs {
|
||||
|
||||
export interface BaseTooltipProps {
|
||||
showTooltipHeader?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
content?: TooltipContentItem[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.tooltip-plugin-container {
|
||||
.tooltipPluginContainer {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
@@ -10,13 +10,9 @@
|
||||
transform: translate(-1000px, -1000px); // hide the tooltip initially
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&.pinned {
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.visible {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import cx from 'classnames';
|
||||
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { syncCursorRegistry } from './syncCursorRegistry';
|
||||
@@ -17,18 +16,21 @@ import {
|
||||
} from './tooltipController';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
TooltipClickData,
|
||||
DEFAULT_PIN_TOOLTIP_KEY,
|
||||
TooltipControllerContext,
|
||||
TooltipControllerState,
|
||||
TooltipLayoutInfo,
|
||||
TooltipPluginProps,
|
||||
TooltipViewState,
|
||||
} from './types';
|
||||
import { createInitialViewState, createLayoutObserver } from './utils';
|
||||
import {
|
||||
buildClickData,
|
||||
createInitialViewState,
|
||||
createLayoutObserver,
|
||||
} from './utils';
|
||||
|
||||
import './TooltipPlugin.styles.scss';
|
||||
import Styles from './TooltipPlugin.module.scss';
|
||||
|
||||
const INTERACTIVE_CONTAINER_CLASSNAME = '.tooltip-plugin-container';
|
||||
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
|
||||
// the plot – this avoids flicker when moving between nearby points.
|
||||
const HOVER_DISMISS_DELAY_MS = 100;
|
||||
@@ -44,6 +46,8 @@ export default function TooltipPlugin({
|
||||
syncMetadata,
|
||||
pinnedTooltipElement,
|
||||
canPinTooltip = false,
|
||||
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
|
||||
onClick,
|
||||
}: TooltipPluginProps): JSX.Element | null {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rafId = useRef<number | null>(null);
|
||||
@@ -131,8 +135,8 @@ export default function TooltipPlugin({
|
||||
// Dismiss the tooltip when the user clicks / presses a key
|
||||
// outside the tooltip container while it is pinned.
|
||||
const onOutsideInteraction = (event: Event): void => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest(INTERACTIVE_CONTAINER_CLASSNAME)) {
|
||||
const target = event.target as Node;
|
||||
if (!containerRef.current?.contains(target)) {
|
||||
dismissTooltip();
|
||||
}
|
||||
};
|
||||
@@ -159,13 +163,14 @@ export default function TooltipPlugin({
|
||||
|
||||
// Attach / detach global listeners when pin state changes so
|
||||
// we can detect when the user interacts outside the tooltip.
|
||||
// Keyboard unpinning is handled exclusively in handleKeyDown so
|
||||
// that only P (toggle) and Escape (release) can dismiss — not
|
||||
// arbitrary keystrokes like arrow keys or Tab.
|
||||
function toggleOutsideListeners(enable: boolean): void {
|
||||
if (enable) {
|
||||
document.addEventListener('mousedown', onOutsideInteraction, true);
|
||||
document.addEventListener('keydown', onOutsideInteraction, true);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', onOutsideInteraction, true);
|
||||
document.removeEventListener('keydown', onOutsideInteraction, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,66 +288,84 @@ 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 => {
|
||||
// Handles all tooltip-pin keyboard interactions:
|
||||
// Escape — always releases the tooltip when pinned (never steals Escape
|
||||
// from other handlers since we do not call stopPropagation).
|
||||
// pinKey — toggles: pins when hovering+unpinned, unpins when pinned.
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
// Escape: release-only (never toggles on).
|
||||
if (event.key === 'Escape') {
|
||||
if (controller.pinned) {
|
||||
dismissTooltip();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() !== pinKey.toLowerCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle off: P pressed while already pinned.
|
||||
if (controller.pinned) {
|
||||
dismissTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle on: P pressed while hovering.
|
||||
const plot = getPlot(controller);
|
||||
if (
|
||||
!plot ||
|
||||
!controller.hoverActive ||
|
||||
controller.focusedSeriesIndex == null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorLeft = plot.cursor.left ?? -1;
|
||||
const cursorTop = plot.cursor.top ?? -1;
|
||||
if (cursorLeft < 0 || cursorTop < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plotRect = plot.over.getBoundingClientRect();
|
||||
const syntheticEvent = ({
|
||||
clientX: plotRect.left + cursorLeft,
|
||||
clientY: plotRect.top + cursorTop,
|
||||
target: plot.over,
|
||||
offsetX: cursorLeft,
|
||||
offsetY: cursorTop,
|
||||
} as unknown) as MouseEvent;
|
||||
|
||||
controller.clickData = buildClickData(syntheticEvent, plot);
|
||||
controller.pinned = true;
|
||||
scheduleRender(true);
|
||||
};
|
||||
|
||||
// Forward overlay clicks to the consumer-provided onClick callback.
|
||||
const handleOverClick = (event: MouseEvent): void => {
|
||||
const plot = getPlot(controller);
|
||||
/**
|
||||
* Only trigger onClick if the click happened on the plot overlay and there is a focused series.
|
||||
* It also ensures that clicks only trigger onClick when there is a relevant data point (i.e. a focused series) to provide context for the click.
|
||||
*/
|
||||
if (
|
||||
plot &&
|
||||
event.target === plot.over &&
|
||||
controller.hoverActive &&
|
||||
!controller.pinned &&
|
||||
controller.focusedSeriesIndex != null
|
||||
) {
|
||||
const xValue = plot.posToVal(event.offsetX, 'x');
|
||||
const yValue = plot.posToVal(event.offsetY, 'y');
|
||||
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
|
||||
|
||||
let clickedDataTimestamp = xValue;
|
||||
if (focusedSeries) {
|
||||
const dataIndex = plot.posToIdx(event.offsetX);
|
||||
const xSeriesData = plot.data[0];
|
||||
if (
|
||||
xSeriesData &&
|
||||
dataIndex >= 0 &&
|
||||
dataIndex < xSeriesData.length &&
|
||||
xSeriesData[dataIndex] !== undefined
|
||||
) {
|
||||
clickedDataTimestamp = xSeriesData[dataIndex];
|
||||
}
|
||||
}
|
||||
|
||||
const clickData: TooltipClickData = {
|
||||
xValue,
|
||||
yValue,
|
||||
focusedSeries,
|
||||
clickedDataTimestamp,
|
||||
mouseX: event.offsetX,
|
||||
mouseY: event.offsetY,
|
||||
absoluteMouseX: event.clientX,
|
||||
absoluteMouseY: event.clientY,
|
||||
};
|
||||
|
||||
controller.clickData = clickData;
|
||||
|
||||
setTimeout(() => {
|
||||
controller.pinned = true;
|
||||
scheduleRender(true);
|
||||
}, 0);
|
||||
const clickData = buildClickData(event, plot);
|
||||
onClick?.(clickData);
|
||||
}
|
||||
};
|
||||
|
||||
let overClickHandler: ((event: MouseEvent) => void) | null = null;
|
||||
|
||||
// Called once per uPlot instance; used to store the instance
|
||||
// on the controller and optionally attach the pinning handler.
|
||||
// Called once per uPlot instance; used to store the instance on the controller.
|
||||
const handleInit = (u: uPlot): void => {
|
||||
controller.plot = u;
|
||||
updateState({ hasPlot: true });
|
||||
if (canPinTooltip) {
|
||||
overClickHandler = handleUPlotOverClick;
|
||||
if (onClick) {
|
||||
overClickHandler = handleOverClick;
|
||||
u.over.addEventListener('click', overClickHandler);
|
||||
}
|
||||
};
|
||||
@@ -389,13 +412,18 @@ export default function TooltipPlugin({
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
if (canPinTooltip) {
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
layoutRef.current?.observer.disconnect();
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
document.removeEventListener('mousedown', onOutsideInteraction, true);
|
||||
document.removeEventListener('keydown', onOutsideInteraction, true);
|
||||
if (canPinTooltip) {
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}
|
||||
cancelPendingRender();
|
||||
removeReadyHook();
|
||||
removeInitHook();
|
||||
@@ -405,9 +433,7 @@ export default function TooltipPlugin({
|
||||
removeSetCursorHook();
|
||||
if (overClickHandler) {
|
||||
const plot = getPlot(controller);
|
||||
if (plot) {
|
||||
plot.over.removeEventListener('click', overClickHandler);
|
||||
}
|
||||
plot?.over.removeEventListener('click', overClickHandler);
|
||||
overClickHandler = null;
|
||||
}
|
||||
clearPlotReferences();
|
||||
@@ -447,8 +473,12 @@ export default function TooltipPlugin({
|
||||
}, [isHovering, hasPlot]);
|
||||
|
||||
const tooltipBody = useMemo(() => {
|
||||
if (isPinned && pinnedTooltipElement != null && viewState.clickData != null) {
|
||||
return pinnedTooltipElement(viewState.clickData);
|
||||
if (isPinned) {
|
||||
if (pinnedTooltipElement != null && viewState.clickData != null) {
|
||||
return pinnedTooltipElement(viewState.clickData);
|
||||
}
|
||||
// No custom pinned element — keep showing the last hover contents.
|
||||
return contents ?? null;
|
||||
}
|
||||
|
||||
if (isHovering) {
|
||||
@@ -471,9 +501,8 @@ export default function TooltipPlugin({
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cx('tooltip-plugin-container', {
|
||||
pinned: isPinned,
|
||||
visible: isTooltipVisible,
|
||||
className={cx(Styles.tooltipPluginContainer, {
|
||||
[Styles.visible]: isTooltipVisible,
|
||||
})}
|
||||
style={{
|
||||
...style,
|
||||
@@ -484,6 +513,7 @@ export default function TooltipPlugin({
|
||||
aria-atomic="true"
|
||||
aria-hidden={!isTooltipVisible}
|
||||
ref={containerRef}
|
||||
data-pinned={isPinned}
|
||||
data-testid="tooltip-plugin-container"
|
||||
>
|
||||
{tooltipBody}
|
||||
|
||||
@@ -102,6 +102,12 @@ export function updateHoverState(
|
||||
controller: TooltipControllerState,
|
||||
syncTooltipWithDashboard: boolean,
|
||||
): void {
|
||||
// When pinned, keep hoverActive stable so the tooltip stays visible
|
||||
// until explicitly dismissed — the cursor lock fires asynchronously
|
||||
// and setSeries/setLegend can otherwise race and clear hoverActive.
|
||||
if (controller.pinned) {
|
||||
return;
|
||||
}
|
||||
// When the cursor is driven by dashboard‑level sync, we only show
|
||||
// the tooltip if the plot is in viewport and at least one series
|
||||
// is active. Otherwise we fall back to local interaction logic.
|
||||
|
||||
@@ -11,6 +11,9 @@ import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
|
||||
|
||||
export const TOOLTIP_OFFSET = 10;
|
||||
|
||||
// Default key that pins the tooltip while hovering over the chart.
|
||||
export const DEFAULT_PIN_TOOLTIP_KEY = 'p';
|
||||
|
||||
export enum DashboardCursorSync {
|
||||
Crosshair,
|
||||
None,
|
||||
@@ -41,6 +44,10 @@ export interface TooltipSyncMetadata {
|
||||
export interface TooltipPluginProps {
|
||||
config: UPlotConfigBuilder;
|
||||
canPinTooltip?: boolean;
|
||||
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
|
||||
pinKey?: string;
|
||||
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
|
||||
onClick?: (clickData: TooltipClickData) => void;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
syncMetadata?: TooltipSyncMetadata;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { TOOLTIP_OFFSET, TooltipLayoutInfo, TooltipViewState } from './types';
|
||||
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
|
||||
import {
|
||||
TOOLTIP_OFFSET,
|
||||
TooltipClickData,
|
||||
TooltipLayoutInfo,
|
||||
TooltipViewState,
|
||||
} from './types';
|
||||
|
||||
export function isPlotInViewport(
|
||||
rect: uPlot.BBox,
|
||||
@@ -158,3 +165,40 @@ export function createLayoutObserver(
|
||||
};
|
||||
return layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a TooltipClickData snapshot from a MouseEvent (real or synthetic)
|
||||
* and the current uPlot instance. Shared by the overlay click handler and the
|
||||
* keyboard-pin handler (which synthesises an event from the cursor position).
|
||||
*/
|
||||
export function buildClickData(
|
||||
event: MouseEvent,
|
||||
plot: uPlot,
|
||||
): TooltipClickData {
|
||||
const xValue = plot.posToVal(event.offsetX, 'x');
|
||||
const yValue = plot.posToVal(event.offsetY, 'y');
|
||||
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
|
||||
|
||||
const dataIndex = plot.posToIdx(event.offsetX);
|
||||
let clickedDataTimestamp = xValue;
|
||||
const xSeriesData = plot.data[0];
|
||||
if (
|
||||
xSeriesData &&
|
||||
dataIndex >= 0 &&
|
||||
dataIndex < xSeriesData.length &&
|
||||
xSeriesData[dataIndex] !== undefined
|
||||
) {
|
||||
clickedDataTimestamp = xSeriesData[dataIndex];
|
||||
}
|
||||
|
||||
return {
|
||||
xValue,
|
||||
yValue,
|
||||
focusedSeries,
|
||||
clickedDataTimestamp,
|
||||
mouseX: event.offsetX,
|
||||
mouseY: event.offsetY,
|
||||
absoluteMouseX: event.clientX,
|
||||
absoluteMouseY: event.clientY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import type uPlot from 'uplot';
|
||||
import { TooltipRenderArgs } from '../../components/types';
|
||||
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
|
||||
import TooltipPlugin from '../TooltipPlugin/TooltipPlugin';
|
||||
import { DashboardCursorSync } from '../TooltipPlugin/types';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
DEFAULT_PIN_TOOLTIP_KEY,
|
||||
} from '../TooltipPlugin/types';
|
||||
|
||||
// Avoid depending on the full uPlot + onClickPlugin behaviour in these tests.
|
||||
// We only care that pinning logic runs without throwing, not which series is focused.
|
||||
@@ -60,7 +63,7 @@ function getHandler(config: ConfigMock, hookName: string): HookHandler {
|
||||
function createFakePlot(): {
|
||||
over: HTMLDivElement;
|
||||
setCursor: jest.Mock<void, [uPlot.Cursor]>;
|
||||
cursor: { event: Record<string, unknown> };
|
||||
cursor: { event: Record<string, unknown>; left: number; top: number };
|
||||
posToVal: jest.Mock<number, [value: number]>;
|
||||
posToIdx: jest.Mock<number, []>;
|
||||
data: [number[], number[]];
|
||||
@@ -71,7 +74,9 @@ function createFakePlot(): {
|
||||
return {
|
||||
over,
|
||||
setCursor: jest.fn(),
|
||||
cursor: { event: {} },
|
||||
// left / top are set to valid values so keyboard-pin tests do not
|
||||
// hit the "cursor off-screen" guard inside handleKeyDown.
|
||||
cursor: { event: {}, left: 50, top: 50 },
|
||||
// In real uPlot these map overlay coordinates to data-space values.
|
||||
posToVal: jest.fn((value: number) => value),
|
||||
posToIdx: jest.fn(() => 0),
|
||||
@@ -144,7 +149,7 @@ describe('TooltipPlugin', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
|
||||
expect(screen.queryByTestId('tooltip-plugin-container')).toBeNull();
|
||||
});
|
||||
|
||||
it('registers all required uPlot hooks on mount', () => {
|
||||
@@ -182,9 +187,7 @@ describe('TooltipPlugin', () => {
|
||||
expect(renderTooltip).toHaveBeenCalled();
|
||||
expect(screen.getByText('tooltip-body')).toBeInTheDocument();
|
||||
|
||||
const container = document.querySelector(
|
||||
'.tooltip-plugin-container',
|
||||
) as HTMLElement;
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container).not.toBeNull();
|
||||
expect(container.parentElement).toBe(document.body);
|
||||
});
|
||||
@@ -203,9 +206,7 @@ describe('TooltipPlugin', () => {
|
||||
|
||||
renderAndActivateHover(config);
|
||||
|
||||
const container = document.querySelector(
|
||||
'.tooltip-plugin-container',
|
||||
) as HTMLElement;
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container.parentElement).toBe(document.body);
|
||||
|
||||
const fullscreenRoot = document.createElement('div');
|
||||
@@ -245,24 +246,27 @@ describe('TooltipPlugin', () => {
|
||||
// ---- Pin behaviour ----------------------------------------------------------
|
||||
|
||||
describe('pin behaviour', () => {
|
||||
it('pins the tooltip when canPinTooltip is true and overlay is clicked', () => {
|
||||
it('pins the tooltip when canPinTooltip is true and the pinKey is pressed while hovering', () => {
|
||||
const config = createConfigMock();
|
||||
|
||||
const fakePlot = renderAndActivateHover(config, undefined, {
|
||||
canPinTooltip: true,
|
||||
});
|
||||
renderAndActivateHover(config, undefined, { canPinTooltip: true });
|
||||
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container.classList.contains('pinned')).toBe(false);
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return waitFor(() => {
|
||||
const updated = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(updated).toBeInTheDocument();
|
||||
expect(updated.classList.contains('pinned')).toBe(true);
|
||||
expect(updated.getAttribute('data-pinned') === 'true').toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,7 +276,7 @@ describe('TooltipPlugin', () => {
|
||||
React.createElement('div', null, 'pinned-tooltip'),
|
||||
);
|
||||
|
||||
const fakePlot = renderAndActivateHover(
|
||||
renderAndActivateHover(
|
||||
config,
|
||||
() => React.createElement('div', null, 'hover-tooltip'),
|
||||
{
|
||||
@@ -284,7 +288,12 @@ describe('TooltipPlugin', () => {
|
||||
expect(screen.getByText('hover-tooltip')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -318,18 +327,20 @@ describe('TooltipPlugin', () => {
|
||||
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
|
||||
});
|
||||
|
||||
// Pin the tooltip.
|
||||
// Pin the tooltip via the keyboard shortcut.
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Wait until the tooltip is actually pinned (pointer events enabled)
|
||||
// Wait until the tooltip is actually pinned.
|
||||
await waitFor(() => {
|
||||
const container = document.querySelector(
|
||||
'.tooltip-plugin-container',
|
||||
) as HTMLElement | null;
|
||||
expect(container).not.toBeNull();
|
||||
expect(container?.classList.contains('pinned')).toBe(true);
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(true);
|
||||
});
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Dismiss' });
|
||||
@@ -342,8 +353,7 @@ describe('TooltipPlugin', () => {
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(container.classList.contains('visible')).toBe(false);
|
||||
expect(container.classList.contains('pinned')).toBe(false);
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -369,16 +379,21 @@ describe('TooltipPlugin', () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Pin.
|
||||
// Pin via keyboard.
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(
|
||||
(document.querySelector(
|
||||
'.tooltip-plugin-container',
|
||||
) as HTMLElement)?.classList.contains('pinned'),
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
|
||||
// Simulate data update – should dismiss the pinned tooltip.
|
||||
@@ -390,8 +405,7 @@ describe('TooltipPlugin', () => {
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(container.classList.contains('visible')).toBe(false);
|
||||
expect(container.classList.contains('pinned')).toBe(false);
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
@@ -417,15 +431,21 @@ describe('TooltipPlugin', () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Pin via keyboard.
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(
|
||||
document
|
||||
.querySelector('.tooltip-plugin-container')
|
||||
?.classList.contains('pinned'),
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
|
||||
// Click outside the tooltip container.
|
||||
@@ -439,14 +459,13 @@ describe('TooltipPlugin', () => {
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(container.classList.contains('visible')).toBe(false);
|
||||
expect(container.classList.contains('pinned')).toBe(false);
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('unpins the tooltip on outside keydown', async () => {
|
||||
it('unpins the tooltip when Escape is pressed while pinned', async () => {
|
||||
jest.useFakeTimers();
|
||||
const config = createConfigMock();
|
||||
|
||||
@@ -467,18 +486,24 @@ describe('TooltipPlugin', () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Pin via keyboard.
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(
|
||||
document
|
||||
.querySelector('.tooltip-plugin-container')
|
||||
?.classList.contains('pinned'),
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
|
||||
// Press a key outside the tooltip.
|
||||
// Press Escape to release.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
@@ -490,12 +515,282 @@ describe('TooltipPlugin', () => {
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(container.classList.contains('visible')).toBe(false);
|
||||
expect(container.classList.contains('pinned')).toBe(false);
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('unpins the tooltip when the pin key is pressed a second time (toggle off)', async () => {
|
||||
jest.useFakeTimers();
|
||||
const config = createConfigMock();
|
||||
|
||||
renderAndActivateHover(config, undefined, { canPinTooltip: true });
|
||||
jest.runAllTimers();
|
||||
|
||||
// First press — pin.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Second press — unpin (toggle off).
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not unpin on Escape when tooltip is not pinned', () => {
|
||||
const config = createConfigMock();
|
||||
renderAndActivateHover(config, undefined, { canPinTooltip: true });
|
||||
|
||||
// Escape without pinning first — should be a no-op.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
// Tooltip should still be hovering (visible), not dismissed.
|
||||
expect(container.getAttribute('aria-hidden')).toBe('false');
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
});
|
||||
|
||||
it('does not unpin on arbitrary keys that are not Escape or the pin key', async () => {
|
||||
jest.useFakeTimers();
|
||||
const config = createConfigMock();
|
||||
|
||||
renderAndActivateHover(config, undefined, { canPinTooltip: true });
|
||||
jest.runAllTimers();
|
||||
|
||||
// Pin.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Arrow key — should NOT unpin.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Keyboard pin edge cases ------------------------------------------------
|
||||
|
||||
describe('keyboard pin edge cases', () => {
|
||||
it('does not pin when cursor coordinates are negative (cursor off-screen)', () => {
|
||||
const config = createConfigMock();
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => React.createElement('div', null, 'tooltip-body'),
|
||||
syncMode: DashboardCursorSync.None,
|
||||
canPinTooltip: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Negative cursor coords — handleKeyDown bails out before pinning.
|
||||
const fakePlot = {
|
||||
...createFakePlot(),
|
||||
cursor: { event: {}, left: -1, top: -1 },
|
||||
};
|
||||
|
||||
act(() => {
|
||||
getHandler(config, 'init')(fakePlot);
|
||||
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
});
|
||||
|
||||
it('does not pin when hover is not active', () => {
|
||||
const config = createConfigMock();
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => React.createElement('div', null, 'tooltip-body'),
|
||||
syncMode: DashboardCursorSync.None,
|
||||
canPinTooltip: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const fakePlot = createFakePlot();
|
||||
|
||||
act(() => {
|
||||
// Initialise the plot but do NOT call setSeries – hoverActive stays false.
|
||||
getHandler(config, 'init')(fakePlot);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// The container exists once the plot is initialised, but it should
|
||||
// be hidden and not pinned since hover was never activated.
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
expect(container.getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
it('ignores other keys and only pins on the configured pinKey', async () => {
|
||||
const config = createConfigMock();
|
||||
|
||||
renderAndActivateHover(config, undefined, {
|
||||
canPinTooltip: true,
|
||||
pinKey: 'p',
|
||||
});
|
||||
|
||||
// 'l' should NOT pin when pinKey is 'p'.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'l',
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Custom pin key 'p' SHOULD pin.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'p', bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not register a keydown listener when canPinTooltip is false', () => {
|
||||
const config = createConfigMock();
|
||||
const addSpy = jest.spyOn(document, 'addEventListener');
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => null,
|
||||
syncMode: DashboardCursorSync.None,
|
||||
canPinTooltip: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const keydownCalls = addSpy.mock.calls.filter(
|
||||
([type]) => type === 'keydown',
|
||||
);
|
||||
expect(keydownCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('removes the keydown pin listener on unmount', () => {
|
||||
const config = createConfigMock();
|
||||
const addSpy = jest.spyOn(document, 'addEventListener');
|
||||
const removeSpy = jest.spyOn(document, 'removeEventListener');
|
||||
|
||||
const { unmount } = render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => null,
|
||||
syncMode: DashboardCursorSync.None,
|
||||
canPinTooltip: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const pinListenerCall = addSpy.mock.calls.find(
|
||||
([type]) => type === 'keydown',
|
||||
);
|
||||
expect(pinListenerCall).toBeDefined();
|
||||
if (!pinListenerCall) {
|
||||
return;
|
||||
}
|
||||
const [, pinListener, pinOptions] = pinListenerCall;
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('keydown', pinListener, pinOptions);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Cursor sync ------------------------------------------------------------
|
||||
|
||||
23
frontend/src/pages/AllErrors/QueryStateContext.ts
Normal file
23
frontend/src/pages/AllErrors/QueryStateContext.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface AllErrorsQueryState {
|
||||
isFetching: boolean;
|
||||
isCancelled: boolean;
|
||||
setIsFetching: (isFetching: boolean) => void;
|
||||
setIsCancelled: (isCancelled: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAllErrorsQueryState = create<AllErrorsQueryState>((set) => ({
|
||||
isFetching: false,
|
||||
isCancelled: false,
|
||||
setIsFetching: (isFetching): void => {
|
||||
set((state) => ({
|
||||
isFetching,
|
||||
// Auto-reset cancelled when a new fetch starts
|
||||
isCancelled: isFetching ? false : state.isCancelled,
|
||||
}));
|
||||
},
|
||||
setIsCancelled: (isCancelled): void => {
|
||||
set({ isCancelled });
|
||||
},
|
||||
}));
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { FilterOutlined } from '@ant-design/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
@@ -19,12 +20,22 @@ import history from 'lib/history';
|
||||
import { isNull } from 'lodash-es';
|
||||
|
||||
import { routes } from './config';
|
||||
import { useAllErrorsQueryState } from './QueryStateContext';
|
||||
|
||||
import './AllErrors.styles.scss';
|
||||
|
||||
function AllErrors(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isLoadingQueries = useAllErrorsQueryState((s) => s.isFetching);
|
||||
const setIsCancelled = useAllErrorsQueryState((s) => s.setIsCancelled);
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries(['getAllErrors']);
|
||||
queryClient.cancelQueries(['getErrorCounts']);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient, setIsCancelled]);
|
||||
|
||||
const [showFilters, setShowFilters] = useState<boolean>(() => {
|
||||
const localStorageValue = getLocalStorageKey(
|
||||
@@ -77,7 +88,11 @@ function AllErrors(): JSX.Element {
|
||||
}
|
||||
rightActions={
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RightToolbarActions onStageRunQuery={handleRunQuery} />
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={handleRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -74,6 +76,27 @@ function LogsExplorer(): JSX.Element {
|
||||
const chartQueryKeyRef = useRef<any>();
|
||||
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
if (listQueryKeyRef.current) {
|
||||
queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
if (chartQueryKeyRef.current) {
|
||||
queryClient.cancelQueries(chartQueryKeyRef.current);
|
||||
}
|
||||
setIsCancelled(true);
|
||||
// Reset loading state — the views container unmounts when cancelled, so
|
||||
// no child will call setIsLoadingQueries(false) otherwise.
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>(undefined);
|
||||
|
||||
@@ -296,10 +319,12 @@ function LogsExplorer(): JSX.Element {
|
||||
}
|
||||
rightActions={
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
listQueryKeyRef={listQueryKeyRef}
|
||||
chartQueryKeyRef={chartQueryKeyRef}
|
||||
onStageRunQuery={(): void => {
|
||||
setIsCancelled(false);
|
||||
handleRunQuery();
|
||||
}}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
showLiveLogs={showLiveLogs}
|
||||
/>
|
||||
}
|
||||
@@ -315,14 +340,18 @@ function LogsExplorer(): JSX.Element {
|
||||
</ExplorerCard>
|
||||
</div>
|
||||
<div className="logs-explorer-views">
|
||||
<LogsExplorerViewsContainer
|
||||
listQueryKeyRef={listQueryKeyRef}
|
||||
chartQueryKeyRef={chartQueryKeyRef}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
setWarning={setWarning}
|
||||
showLiveLogs={showLiveLogs}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
{isCancelled ? (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load logs.' />
|
||||
) : (
|
||||
<LogsExplorerViewsContainer
|
||||
listQueryKeyRef={listQueryKeyRef}
|
||||
chartQueryKeyRef={chartQueryKeyRef}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
setWarning={setWarning}
|
||||
showLiveLogs={showLiveLogs}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -71,11 +73,29 @@ function TracesExplorer(): JSX.Element {
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const listQueryKeyRef = useRef<any>();
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
if (listQueryKeyRef.current) {
|
||||
queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
setIsCancelled(true);
|
||||
// Reset loading state — the active view unmounts when cancelled, so no
|
||||
// child will call setIsLoadingQueries(false) otherwise.
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
@@ -210,9 +230,12 @@ function TracesExplorer(): JSX.Element {
|
||||
}
|
||||
rightActions={
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
onStageRunQuery={(): void => {
|
||||
setIsCancelled(false);
|
||||
handleRunQuery();
|
||||
}}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
listQueryKeyRef={listQueryKeyRef}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -224,7 +247,11 @@ function TracesExplorer(): JSX.Element {
|
||||
</ExplorerCard>
|
||||
|
||||
<div className="traces-explorer-views">
|
||||
{selectedView === ExplorerViews.LIST && (
|
||||
{isCancelled && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load traces.' />
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.LIST && (
|
||||
<div className="trace-explorer-list-view">
|
||||
<ListView
|
||||
isFilterApplied={isFilterApplied}
|
||||
@@ -235,7 +262,7 @@ function TracesExplorer(): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedView === ExplorerViews.TRACE && (
|
||||
{!isCancelled && selectedView === ExplorerViews.TRACE && (
|
||||
<div className="trace-explorer-traces-view">
|
||||
<TracesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
@@ -246,7 +273,7 @@ function TracesExplorer(): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedView === ExplorerViews.TIMESERIES && (
|
||||
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
|
||||
<div className="trace-explorer-time-series-view">
|
||||
<TimeSeriesView
|
||||
dataSource={DataSource.TRACES}
|
||||
@@ -258,7 +285,7 @@ function TracesExplorer(): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedView === ExplorerViews.TABLE && (
|
||||
{!isCancelled && selectedView === ExplorerViews.TABLE && (
|
||||
<div className="trace-explorer-table-view">
|
||||
<TableView
|
||||
setWarning={setWarning}
|
||||
|
||||
@@ -5593,10 +5593,10 @@
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/ui@0.0.9":
|
||||
version "0.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.9.tgz#e00f2ec86c5528eea91d1669510a702c4253de0d"
|
||||
integrity sha512-L9DV0OF69Z2sMnxwPEGpSTiDxI/liT6+QfzngGVnxMl/9t3WKcPr1/p8dbPOcAS6bU9lQEBOiYlsoIejM8DsXw==
|
||||
"@signozhq/ui@0.0.10":
|
||||
version "0.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.10.tgz#cdbab838f8cb543cf5b483a86e9d9b65265b81ff"
|
||||
integrity sha512-XLeET+PgSP7heqKMsb9YZOSRT3TpfMPHNQRnY1I4SK8mXSct7BYWwK0Q3Je0uf4Z3aWOcpRYoRUPHWZQBpweFQ==
|
||||
dependencies:
|
||||
"@chenglou/pretext" "^0.0.5"
|
||||
"@radix-ui/react-checkbox" "^1.2.3"
|
||||
|
||||
1
go.mod
1
go.mod
@@ -66,6 +66,7 @@ require (
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.2.9
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9
|
||||
github.com/uptrace/bun/extra/bunotel v1.2.9
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
go.opentelemetry.io/collector/confmap v1.51.0
|
||||
go.opentelemetry.io/collector/otelcol v0.144.0
|
||||
go.opentelemetry.io/collector/pdata v1.51.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1153,6 +1153,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=
|
||||
|
||||
33
pkg/apiserver/signozapiserver/inframonitoring.go
Normal file
33
pkg/apiserver/signozapiserver/inframonitoring.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/infra_monitoring/hosts", handler.New(
|
||||
provider.authZ.ViewAccess(provider.infraMonitoringHandler.ListHosts),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListHosts",
|
||||
Tags: []string{"inframonitoring"},
|
||||
Summary: "List Hosts for Infra Monitoring",
|
||||
Description: "Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary.",
|
||||
Request: new(inframonitoringtypes.PostableHosts),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(inframonitoringtypes.Hosts),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanattributemapping"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
@@ -34,33 +36,35 @@ import (
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
config apiserver.Config
|
||||
settings factory.ScopedProviderSettings
|
||||
router *mux.Router
|
||||
authZ *middleware.AuthZ
|
||||
orgHandler organization.Handler
|
||||
userHandler user.Handler
|
||||
sessionHandler session.Handler
|
||||
authDomainHandler authdomain.Handler
|
||||
preferenceHandler preference.Handler
|
||||
globalHandler global.Handler
|
||||
promoteHandler promote.Handler
|
||||
flaggerHandler flagger.Handler
|
||||
dashboardModule dashboard.Module
|
||||
dashboardHandler dashboard.Handler
|
||||
metricsExplorerHandler metricsexplorer.Handler
|
||||
gatewayHandler gateway.Handler
|
||||
fieldsHandler fields.Handler
|
||||
authzHandler authz.Handler
|
||||
rawDataExportHandler rawdataexport.Handler
|
||||
zeusHandler zeus.Handler
|
||||
querierHandler querier.Handler
|
||||
serviceAccountHandler serviceaccount.Handler
|
||||
factoryHandler factory.Handler
|
||||
cloudIntegrationHandler cloudintegration.Handler
|
||||
ruleStateHistoryHandler rulestatehistory.Handler
|
||||
alertmanagerHandler alertmanager.Handler
|
||||
rulerHandler ruler.Handler
|
||||
config apiserver.Config
|
||||
settings factory.ScopedProviderSettings
|
||||
router *mux.Router
|
||||
authZ *middleware.AuthZ
|
||||
orgHandler organization.Handler
|
||||
userHandler user.Handler
|
||||
sessionHandler session.Handler
|
||||
authDomainHandler authdomain.Handler
|
||||
preferenceHandler preference.Handler
|
||||
globalHandler global.Handler
|
||||
promoteHandler promote.Handler
|
||||
flaggerHandler flagger.Handler
|
||||
dashboardModule dashboard.Module
|
||||
dashboardHandler dashboard.Handler
|
||||
metricsExplorerHandler metricsexplorer.Handler
|
||||
infraMonitoringHandler inframonitoring.Handler
|
||||
gatewayHandler gateway.Handler
|
||||
fieldsHandler fields.Handler
|
||||
authzHandler authz.Handler
|
||||
rawDataExportHandler rawdataexport.Handler
|
||||
zeusHandler zeus.Handler
|
||||
querierHandler querier.Handler
|
||||
serviceAccountHandler serviceaccount.Handler
|
||||
factoryHandler factory.Handler
|
||||
cloudIntegrationHandler cloudintegration.Handler
|
||||
ruleStateHistoryHandler rulestatehistory.Handler
|
||||
spanAttributeMappingHandler spanattributemapping.Handler
|
||||
alertmanagerHandler alertmanager.Handler
|
||||
rulerHandler ruler.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -77,6 +81,7 @@ func NewFactory(
|
||||
dashboardModule dashboard.Module,
|
||||
dashboardHandler dashboard.Handler,
|
||||
metricsExplorerHandler metricsexplorer.Handler,
|
||||
infraMonitoringHandler inframonitoring.Handler,
|
||||
gatewayHandler gateway.Handler,
|
||||
fieldsHandler fields.Handler,
|
||||
authzHandler authz.Handler,
|
||||
@@ -87,6 +92,7 @@ func NewFactory(
|
||||
factoryHandler factory.Handler,
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanAttributeMappingHandler spanattributemapping.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
@@ -108,6 +114,7 @@ func NewFactory(
|
||||
dashboardModule,
|
||||
dashboardHandler,
|
||||
metricsExplorerHandler,
|
||||
infraMonitoringHandler,
|
||||
gatewayHandler,
|
||||
fieldsHandler,
|
||||
authzHandler,
|
||||
@@ -118,6 +125,7 @@ func NewFactory(
|
||||
factoryHandler,
|
||||
cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler,
|
||||
spanAttributeMappingHandler,
|
||||
alertmanagerHandler,
|
||||
rulerHandler,
|
||||
)
|
||||
@@ -141,6 +149,7 @@ func newProvider(
|
||||
dashboardModule dashboard.Module,
|
||||
dashboardHandler dashboard.Handler,
|
||||
metricsExplorerHandler metricsexplorer.Handler,
|
||||
infraMonitoringHandler inframonitoring.Handler,
|
||||
gatewayHandler gateway.Handler,
|
||||
fieldsHandler fields.Handler,
|
||||
authzHandler authz.Handler,
|
||||
@@ -151,6 +160,7 @@ func newProvider(
|
||||
factoryHandler factory.Handler,
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanAttributeMappingHandler spanattributemapping.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
@@ -158,32 +168,34 @@ func newProvider(
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
|
||||
provider := &provider{
|
||||
config: config,
|
||||
settings: settings,
|
||||
router: router,
|
||||
orgHandler: orgHandler,
|
||||
userHandler: userHandler,
|
||||
sessionHandler: sessionHandler,
|
||||
authDomainHandler: authDomainHandler,
|
||||
preferenceHandler: preferenceHandler,
|
||||
globalHandler: globalHandler,
|
||||
promoteHandler: promoteHandler,
|
||||
flaggerHandler: flaggerHandler,
|
||||
dashboardModule: dashboardModule,
|
||||
dashboardHandler: dashboardHandler,
|
||||
metricsExplorerHandler: metricsExplorerHandler,
|
||||
gatewayHandler: gatewayHandler,
|
||||
fieldsHandler: fieldsHandler,
|
||||
authzHandler: authzHandler,
|
||||
rawDataExportHandler: rawDataExportHandler,
|
||||
zeusHandler: zeusHandler,
|
||||
querierHandler: querierHandler,
|
||||
serviceAccountHandler: serviceAccountHandler,
|
||||
factoryHandler: factoryHandler,
|
||||
cloudIntegrationHandler: cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler: ruleStateHistoryHandler,
|
||||
alertmanagerHandler: alertmanagerHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
config: config,
|
||||
settings: settings,
|
||||
router: router,
|
||||
orgHandler: orgHandler,
|
||||
userHandler: userHandler,
|
||||
sessionHandler: sessionHandler,
|
||||
authDomainHandler: authDomainHandler,
|
||||
preferenceHandler: preferenceHandler,
|
||||
globalHandler: globalHandler,
|
||||
promoteHandler: promoteHandler,
|
||||
flaggerHandler: flaggerHandler,
|
||||
dashboardModule: dashboardModule,
|
||||
dashboardHandler: dashboardHandler,
|
||||
metricsExplorerHandler: metricsExplorerHandler,
|
||||
infraMonitoringHandler: infraMonitoringHandler,
|
||||
gatewayHandler: gatewayHandler,
|
||||
fieldsHandler: fieldsHandler,
|
||||
authzHandler: authzHandler,
|
||||
rawDataExportHandler: rawDataExportHandler,
|
||||
zeusHandler: zeusHandler,
|
||||
querierHandler: querierHandler,
|
||||
serviceAccountHandler: serviceAccountHandler,
|
||||
factoryHandler: factoryHandler,
|
||||
cloudIntegrationHandler: cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler: ruleStateHistoryHandler,
|
||||
spanAttributeMappingHandler: spanAttributeMappingHandler,
|
||||
alertmanagerHandler: alertmanagerHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
}
|
||||
|
||||
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
|
||||
@@ -240,6 +252,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addInfraMonitoringRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addGatewayRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -284,6 +300,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addSpanAttributeMappingRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addAlertmanagerRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
171
pkg/apiserver/signozapiserver/spanattributemapping.go
Normal file
171
pkg/apiserver/signozapiserver/spanattributemapping.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/spanattributemappingtypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addSpanAttributeMappingRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/span_attribute_mapping_groups", handler.New(
|
||||
provider.authZ.ViewAccess(provider.spanAttributeMappingHandler.ListGroups),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListSpanAttributeMappingGroups",
|
||||
Tags: []string{"span-attribute-mapping"},
|
||||
Summary: "List span attribute mapping groups",
|
||||
Description: "Returns all span attribute mapping groups for the authenticated org.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
RequestQuery: new(spanattributemappingtypes.ListGroupsQuery),
|
||||
Response: new(spanattributemappingtypes.GettableGroups),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_attribute_mapping_groups", handler.New(
|
||||
provider.authZ.AdminAccess(provider.spanAttributeMappingHandler.CreateGroup),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateMappingGroup",
|
||||
Tags: []string{"span-attribute-mapping"},
|
||||
Summary: "Create a span attribute mapping group",
|
||||
Description: "Creates a new span attribute mapping group for the org.",
|
||||
Request: new(spanattributemappingtypes.PostableGroup),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(spanattributemappingtypes.GettableGroup),
|
||||
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/span_attribute_mapping_groups/{groupId}", handler.New(
|
||||
provider.authZ.AdminAccess(provider.spanAttributeMappingHandler.UpdateGroup),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateMappingGroup",
|
||||
Tags: []string{"span-attribute-mapping"},
|
||||
Summary: "Update a span attribute mapping group",
|
||||
Description: "Partially updates an existing mapping group's name, condition, or enabled state.",
|
||||
Request: new(spanattributemappingtypes.UpdatableGroup),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_attribute_mapping_groups/{groupId}", handler.New(
|
||||
provider.authZ.AdminAccess(provider.spanAttributeMappingHandler.DeleteGroup),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteMappingGroup",
|
||||
Tags: []string{"span-attribute-mapping"},
|
||||
Summary: "Delete a span attribute mapping group",
|
||||
Description: "Hard-deletes a mapping group and cascades to all its mappers.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
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/span_attribute_mapping_groups/{groupId}/mappers", handler.New(
|
||||
provider.authZ.ViewAccess(provider.spanAttributeMappingHandler.ListMappers),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListMappers",
|
||||
Tags: []string{"span-attribute-mapping"},
|
||||
Summary: "List span attribute mappers for a group",
|
||||
Description: "Returns all attribute mappers belonging to a mapping group.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(spanattributemappingtypes.GettableMappers),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_attribute_mapping_groups/{groupId}/mappers", handler.New(
|
||||
provider.authZ.AdminAccess(provider.spanAttributeMappingHandler.CreateMapper),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateMapper",
|
||||
Tags: []string{"span-attribute-mapping"},
|
||||
Summary: "Create a span attribute mapper",
|
||||
Description: "Adds a new attribute mapper to the specified mapping group.",
|
||||
Request: new(spanattributemappingtypes.PostableMapper),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(spanattributemappingtypes.GettableMapper),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_attribute_mapping_groups/{groupId}/mappers/{mapperId}", handler.New(
|
||||
provider.authZ.AdminAccess(provider.spanAttributeMappingHandler.UpdateMapper),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateMapper",
|
||||
Tags: []string{"span-attribute-mapping"},
|
||||
Summary: "Update a span attribute mapper",
|
||||
Description: "Partially updates an existing mapper's field context, config, or enabled state.",
|
||||
Request: new(spanattributemappingtypes.UpdatableMapper),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_attribute_mapping_groups/{groupId}/mappers/{mapperId}", handler.New(
|
||||
provider.authZ.AdminAccess(provider.spanAttributeMappingHandler.DeleteMapper),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteMapper",
|
||||
Tags: []string{"span-attribute-mapping"},
|
||||
Summary: "Delete a span attribute mapper",
|
||||
Description: "Hard-deletes a mapper from a mapping group.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
33
pkg/modules/inframonitoring/config.go
Normal file
33
pkg/modules/inframonitoring/config.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package inframonitoring
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
TelemetryStore TelemetryStoreConfig `mapstructure:"telemetrystore"`
|
||||
}
|
||||
|
||||
type TelemetryStoreConfig struct {
|
||||
Threads int `mapstructure:"threads"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(factory.MustNewName("inframonitoring"), newConfig)
|
||||
}
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return Config{
|
||||
TelemetryStore: TelemetryStoreConfig{
|
||||
Threads: 8,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.TelemetryStore.Threads <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "inframonitoring.telemetrystore.threads must be positive, got %d", c.TelemetryStore.Threads)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
47
pkg/modules/inframonitoring/implinframonitoring/handler.go
Normal file
47
pkg/modules/inframonitoring/implinframonitoring/handler.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module inframonitoring.Module
|
||||
}
|
||||
|
||||
// NewHandler returns an inframonitoring.Handler implementation.
|
||||
func NewHandler(m inframonitoring.Module) inframonitoring.Handler {
|
||||
return &handler{
|
||||
module: m,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) ListHosts(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
var parsedReq inframonitoringtypes.PostableHosts
|
||||
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.ListHosts(req.Context(), orgID, &parsedReq)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
573
pkg/modules/inframonitoring/implinframonitoring/helpers.go
Normal file
573
pkg/modules/inframonitoring/implinframonitoring/helpers.go
Normal file
@@ -0,0 +1,573 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// quoteIdentifier wraps s in backticks for use as a ClickHouse identifier,
|
||||
// escaping any embedded backticks by doubling them.
|
||||
func quoteIdentifier(s string) string {
|
||||
return fmt.Sprintf("`%s`", strings.ReplaceAll(s, "`", "``"))
|
||||
}
|
||||
|
||||
func isKeyInGroupByAttrs(groupByAttrs []qbtypes.GroupByKey, key string) bool {
|
||||
for _, groupBy := range groupByAttrs {
|
||||
if groupBy.Name == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeFilterExpressions(queryFilterExpr, reqFilterExpr string) string {
|
||||
queryFilterExpr = strings.TrimSpace(queryFilterExpr)
|
||||
reqFilterExpr = strings.TrimSpace(reqFilterExpr)
|
||||
if queryFilterExpr == "" {
|
||||
return reqFilterExpr
|
||||
}
|
||||
if reqFilterExpr == "" {
|
||||
return queryFilterExpr
|
||||
}
|
||||
return fmt.Sprintf("(%s) AND (%s)", queryFilterExpr, reqFilterExpr)
|
||||
}
|
||||
|
||||
// compositeKeyFromList builds a composite key by joining the given parts
|
||||
// with a null byte separator. This is the canonical way to construct
|
||||
// composite keys for group identification across the infra monitoring module.
|
||||
func compositeKeyFromList(parts []string) string {
|
||||
return strings.Join(parts, "\x00")
|
||||
}
|
||||
|
||||
// compositeKeyFromLabels builds a composite key from a label map by extracting
|
||||
// the value for each groupBy key in order and joining them via compositeKeyFromList.
|
||||
func compositeKeyFromLabels(labels map[string]string, groupBy []qbtypes.GroupByKey) string {
|
||||
parts := make([]string, len(groupBy))
|
||||
for i, key := range groupBy {
|
||||
parts[i] = labels[key.Name]
|
||||
}
|
||||
return compositeKeyFromList(parts)
|
||||
}
|
||||
|
||||
// parseAndSortGroups extracts group label maps from a ScalarData response and
|
||||
// sorts them by the ranking query's aggregation value.
|
||||
func parseAndSortGroups(
|
||||
resp *qbtypes.QueryRangeResponse,
|
||||
rankingQueryName string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
direction qbtypes.OrderDirection,
|
||||
) []rankedGroup {
|
||||
if resp == nil || len(resp.Data.Results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the ScalarData that contains the ranking column.
|
||||
var sd *qbtypes.ScalarData
|
||||
for _, r := range resp.Data.Results {
|
||||
candidate, ok := r.(*qbtypes.ScalarData)
|
||||
if !ok || candidate == nil {
|
||||
continue
|
||||
}
|
||||
for _, col := range candidate.Columns {
|
||||
if col.Type == qbtypes.ColumnTypeAggregation && col.QueryName == rankingQueryName {
|
||||
sd = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if sd != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if sd == nil || len(sd.Data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
groupColIndices := make(map[string]int)
|
||||
rankingColIdx := -1
|
||||
for i, col := range sd.Columns {
|
||||
if col.Type == qbtypes.ColumnTypeGroup {
|
||||
groupColIndices[col.Name] = i
|
||||
}
|
||||
if col.Type == qbtypes.ColumnTypeAggregation && col.QueryName == rankingQueryName {
|
||||
rankingColIdx = i
|
||||
}
|
||||
}
|
||||
if rankingColIdx == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
groups := make([]rankedGroup, 0, len(sd.Data))
|
||||
for _, row := range sd.Data {
|
||||
labels := make(map[string]string, len(groupBy))
|
||||
for _, key := range groupBy {
|
||||
if idx, ok := groupColIndices[key.Name]; ok && idx < len(row) {
|
||||
labels[key.Name] = fmt.Sprintf("%v", row[idx])
|
||||
}
|
||||
}
|
||||
var value float64
|
||||
if rankingColIdx < len(row) {
|
||||
if v, ok := row[rankingColIdx].(float64); ok {
|
||||
value = v
|
||||
}
|
||||
}
|
||||
groups = append(groups, rankedGroup{
|
||||
labels: labels,
|
||||
value: value,
|
||||
compositeKey: compositeKeyFromLabels(labels, groupBy),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
if groups[i].value != groups[j].value {
|
||||
if direction == qbtypes.OrderDirectionAsc {
|
||||
return groups[i].value < groups[j].value
|
||||
}
|
||||
return groups[i].value > groups[j].value
|
||||
}
|
||||
return groups[i].compositeKey < groups[j].compositeKey
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// paginateWithBackfill returns the page of groups for [offset, offset+limit).
|
||||
// The virtual sorted list is: metric-ranked groups first, then metadata-only
|
||||
// groups (those in metadataMap but not in metric results) sorted alphabetically.
|
||||
func paginateWithBackfill(
|
||||
metricGroups []rankedGroup,
|
||||
metadataMap map[string]map[string]string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
offset, limit int,
|
||||
) []map[string]string {
|
||||
metricKeySet := make(map[string]bool, len(metricGroups))
|
||||
for _, g := range metricGroups {
|
||||
metricKeySet[g.compositeKey] = true
|
||||
}
|
||||
|
||||
metadataOnlyKeys := make([]string, 0)
|
||||
for compositeKey := range metadataMap {
|
||||
if !metricKeySet[compositeKey] {
|
||||
metadataOnlyKeys = append(metadataOnlyKeys, compositeKey)
|
||||
}
|
||||
}
|
||||
sort.Strings(metadataOnlyKeys)
|
||||
|
||||
totalMetric := len(metricGroups)
|
||||
totalAll := totalMetric + len(metadataOnlyKeys)
|
||||
|
||||
end := offset + limit
|
||||
if end > totalAll {
|
||||
end = totalAll
|
||||
}
|
||||
if offset >= totalAll {
|
||||
return nil
|
||||
}
|
||||
|
||||
pageGroups := make([]map[string]string, 0, end-offset)
|
||||
for i := offset; i < end; i++ {
|
||||
if i < totalMetric {
|
||||
pageGroups = append(pageGroups, metricGroups[i].labels)
|
||||
} else {
|
||||
compositeKey := metadataOnlyKeys[i-totalMetric]
|
||||
attrs := metadataMap[compositeKey]
|
||||
labels := make(map[string]string, len(groupBy))
|
||||
for _, key := range groupBy {
|
||||
labels[key.Name] = attrs[key.Name]
|
||||
}
|
||||
pageGroups = append(pageGroups, labels)
|
||||
}
|
||||
}
|
||||
return pageGroups
|
||||
}
|
||||
|
||||
// buildPageGroupsFilterExpr builds a filter expression that restricts results
|
||||
// to the given page of groups via IN clauses.
|
||||
// Returns e.g. "host.name IN ('h1','h2') AND os.type IN ('linux','windows')".
|
||||
func buildPageGroupsFilterExpr(pageGroups []map[string]string) string {
|
||||
groupValues := make(map[string][]string)
|
||||
for _, labels := range pageGroups {
|
||||
for k, v := range labels {
|
||||
groupValues[k] = append(groupValues[k], v)
|
||||
}
|
||||
}
|
||||
|
||||
inClauses := make([]string, 0, len(groupValues))
|
||||
for key, values := range groupValues {
|
||||
quoted := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
quoted[i] = fmt.Sprintf("'%s'", v)
|
||||
}
|
||||
inClauses = append(inClauses, fmt.Sprintf("%s IN (%s)", key, strings.Join(quoted, ", ")))
|
||||
}
|
||||
return strings.Join(inClauses, " AND ")
|
||||
}
|
||||
|
||||
// buildFullQueryRequest creates a QueryRangeRequest for all metrics,
|
||||
// restricted to the given page of groups via an IN filter.
|
||||
// Accepts primitive fields so it can be reused across different v2 APIs
|
||||
// (hosts, pods, etc.).
|
||||
func buildFullQueryRequest(
|
||||
start int64,
|
||||
end int64,
|
||||
filterExpr string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
pageGroups []map[string]string,
|
||||
tableListQuery *qbtypes.QueryRangeRequest,
|
||||
) *qbtypes.QueryRangeRequest {
|
||||
inFilterExpr := buildPageGroupsFilterExpr(pageGroups)
|
||||
|
||||
fullReq := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start),
|
||||
End: uint64(end),
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: make([]qbtypes.QueryEnvelope, 0, len(tableListQuery.CompositeQuery.Queries)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, envelope := range tableListQuery.CompositeQuery.Queries {
|
||||
copied := envelope
|
||||
if copied.Type == qbtypes.QueryTypeBuilder {
|
||||
existingExpr := ""
|
||||
if f := copied.GetFilter(); f != nil {
|
||||
existingExpr = f.Expression
|
||||
}
|
||||
merged := mergeFilterExpressions(existingExpr, filterExpr)
|
||||
merged = mergeFilterExpressions(merged, inFilterExpr)
|
||||
copied.SetFilter(&qbtypes.Filter{Expression: merged})
|
||||
copied.SetGroupBy(groupBy)
|
||||
}
|
||||
fullReq.CompositeQuery.Queries = append(fullReq.CompositeQuery.Queries, copied)
|
||||
}
|
||||
|
||||
return fullReq
|
||||
}
|
||||
|
||||
// parseFullQueryResponse extracts per-group metric values from the full
|
||||
// composite query response. Returns compositeKey -> (queryName -> value).
|
||||
// Each enabled query/formula produces its own ScalarData entry in Results,
|
||||
// so we iterate over all of them and merge metrics per composite key.
|
||||
func parseFullQueryResponse(
|
||||
resp *qbtypes.QueryRangeResponse,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
) map[string]map[string]float64 {
|
||||
result := make(map[string]map[string]float64)
|
||||
if resp == nil || len(resp.Data.Results) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
for _, r := range resp.Data.Results {
|
||||
sd, ok := r.(*qbtypes.ScalarData)
|
||||
if !ok || sd == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
groupColIndices := make(map[string]int)
|
||||
aggCols := make(map[int]string) // col index -> query name
|
||||
for i, col := range sd.Columns {
|
||||
if col.Type == qbtypes.ColumnTypeGroup {
|
||||
groupColIndices[col.Name] = i
|
||||
}
|
||||
if col.Type == qbtypes.ColumnTypeAggregation {
|
||||
aggCols[i] = col.QueryName
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range sd.Data {
|
||||
labels := make(map[string]string, len(groupBy))
|
||||
for _, key := range groupBy {
|
||||
if idx, ok := groupColIndices[key.Name]; ok && idx < len(row) {
|
||||
labels[key.Name] = fmt.Sprintf("%v", row[idx])
|
||||
}
|
||||
}
|
||||
compositeKey := compositeKeyFromLabels(labels, groupBy)
|
||||
|
||||
if result[compositeKey] == nil {
|
||||
result[compositeKey] = make(map[string]float64)
|
||||
}
|
||||
for idx, queryName := range aggCols {
|
||||
if idx < len(row) {
|
||||
if v, ok := row[idx].(float64); ok {
|
||||
result[compositeKey][queryName] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildSamplesTblFingerprintSubQuery returns a SelectBuilder that selects distinct fingerprints
|
||||
// from the samples table for the given metric names andtime range.
|
||||
func (m *module) buildSamplesTblFingerprintSubQuery(metricNames []string, startMs, endMs int64) *sqlbuilder.SelectBuilder {
|
||||
samplesTableName := telemetrymetrics.WhichSamplesTableToUse(
|
||||
uint64(startMs), uint64(endMs),
|
||||
metrictypes.UnspecifiedType,
|
||||
metrictypes.TimeAggregationUnspecified,
|
||||
nil,
|
||||
)
|
||||
localSamplesTable := strings.TrimPrefix(samplesTableName, "distributed_")
|
||||
fpSB := sqlbuilder.NewSelectBuilder()
|
||||
fpSB.Select("DISTINCT fingerprint")
|
||||
fpSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localSamplesTable))
|
||||
fpSB.Where(
|
||||
fpSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
fpSB.GE("unix_milli", startMs),
|
||||
fpSB.L("unix_milli", endMs),
|
||||
)
|
||||
return fpSB
|
||||
}
|
||||
|
||||
func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter, startMillis, endMillis int64) (*sqlbuilder.WhereClause, error) {
|
||||
expression := ""
|
||||
if filter != nil {
|
||||
expression = strings.TrimSpace(filter.Expression)
|
||||
}
|
||||
if expression == "" {
|
||||
return sqlbuilder.NewWhereClause(), nil
|
||||
}
|
||||
|
||||
whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(expression)
|
||||
for idx := range whereClauseSelectors {
|
||||
whereClauseSelectors[idx].Signal = telemetrytypes.SignalMetrics
|
||||
whereClauseSelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
|
||||
}
|
||||
|
||||
keys, _, err := m.telemetryMetadataStore.GetKeysMulti(ctx, whereClauseSelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
Logger: m.logger,
|
||||
FieldMapper: m.fieldMapper,
|
||||
ConditionBuilder: m.condBuilder,
|
||||
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
|
||||
FieldKeys: keys,
|
||||
StartNs: querybuilder.ToNanoSecs(uint64(startMillis)),
|
||||
EndNs: querybuilder.ToNanoSecs(uint64(endMillis)),
|
||||
}
|
||||
|
||||
whereClause, err := querybuilder.PrepareWhereClause(expression, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if whereClause == nil || whereClause.WhereClause == nil {
|
||||
return sqlbuilder.NewWhereClause(), nil
|
||||
}
|
||||
|
||||
return whereClause.WhereClause, nil
|
||||
}
|
||||
|
||||
// NOTE: this method is not specific to infra monitoring — it queries attributes_metadata generically.
|
||||
// Consider moving to telemetryMetaStore when a second use case emerges.
|
||||
//
|
||||
// getMetricsExistenceAndEarliestTime checks which of the given metric names have been
|
||||
// reported. It returns a list of missing metrics (those not found or with zero count)
|
||||
// and the earliest first-reported timestamp across all present metrics.
|
||||
// When all metrics are missing, minFirstReportedUnixMilli is 0.
|
||||
// TODO(nikhilmantri0902, srikanthccv): This method was designed this way because querier errors if any of the metrics
|
||||
// in the querier list was never sent, the QueryRange call throws not found error. Modify this method, if QueryRange
|
||||
// behaviour changes towards this.
|
||||
func (m *module) getMetricsExistenceAndEarliestTime(ctx context.Context, metricNames []string) ([]string, uint64, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "count(*) AS cnt", "min(first_reported_unix_milli) AS min_first_reported")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type metricInfo struct {
|
||||
count uint64
|
||||
minFirstReported uint64
|
||||
}
|
||||
found := make(map[string]metricInfo, len(metricNames))
|
||||
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var cnt, minFR uint64
|
||||
if err := rows.Scan(&name, &cnt, &minFR); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
found[name] = metricInfo{count: cnt, minFirstReported: minFR}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var missingMetrics []string
|
||||
var globalMinFirstReported uint64
|
||||
for _, name := range metricNames {
|
||||
info, ok := found[name]
|
||||
if !ok || info.count == 0 {
|
||||
missingMetrics = append(missingMetrics, name)
|
||||
continue
|
||||
}
|
||||
if globalMinFirstReported == 0 || info.minFirstReported < globalMinFirstReported {
|
||||
globalMinFirstReported = info.minFirstReported
|
||||
}
|
||||
}
|
||||
|
||||
return missingMetrics, globalMinFirstReported, nil
|
||||
}
|
||||
|
||||
// getMetadata fetches the latest values of additionalCols for each unique combination of groupBy keys,
|
||||
// within the given time range and metric names. It uses argMax(tuple(...), unix_milli) to ensure
|
||||
// we always pick attribute values from the latest timestamp for each group.
|
||||
// The returned map has a composite key of groupBy column values joined by "\x00" (null byte),
|
||||
// mapping to a flat map of attr_name -> attr_value (includes both groupBy and additional cols).
|
||||
func (m *module) getMetadata(
|
||||
ctx context.Context,
|
||||
metricNames []string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
additionalCols []string,
|
||||
filter *qbtypes.Filter,
|
||||
startMs, endMs int64,
|
||||
) (map[string]map[string]string, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricNames must not be empty")
|
||||
}
|
||||
if len(groupBy) == 0 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "groupBy must not be empty")
|
||||
}
|
||||
|
||||
// Pick the optimal timeseries table based on time range; also get adjusted start.
|
||||
adjustedStart, adjustedEnd, distributedTableName, _ := telemetrymetrics.WhichTSTableToUse(
|
||||
uint64(startMs), uint64(endMs), nil,
|
||||
)
|
||||
|
||||
fpSB := m.buildSamplesTblFingerprintSubQuery(metricNames, startMs, endMs)
|
||||
|
||||
// Flatten groupBy keys to string names for SQL expressions and result scanning.
|
||||
groupByCols := make([]string, len(groupBy))
|
||||
for i, key := range groupBy {
|
||||
groupByCols[i] = key.Name
|
||||
}
|
||||
allCols := append(groupByCols, additionalCols...)
|
||||
|
||||
// --- Build inner query ---
|
||||
innerSB := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
// Inner SELECT columns: JSONExtractString for each groupBy col + argMax(tuple(...)) for additional cols
|
||||
innerSelectCols := make([]string, 0, len(groupByCols)+1)
|
||||
for _, col := range groupByCols {
|
||||
innerSelectCols = append(innerSelectCols,
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", innerSB.Var(col), quoteIdentifier(col)),
|
||||
)
|
||||
}
|
||||
|
||||
// Build the argMax(tuple(...), unix_milli) expression for all additional cols
|
||||
if len(additionalCols) > 0 {
|
||||
tupleArgs := make([]string, 0, len(additionalCols))
|
||||
for _, col := range additionalCols {
|
||||
tupleArgs = append(tupleArgs, fmt.Sprintf("JSONExtractString(labels, %s)", innerSB.Var(col)))
|
||||
}
|
||||
innerSelectCols = append(innerSelectCols,
|
||||
fmt.Sprintf("argMax(tuple(%s), unix_milli) AS latest_attrs", strings.Join(tupleArgs, ", ")),
|
||||
)
|
||||
}
|
||||
|
||||
innerSB.Select(innerSelectCols...)
|
||||
innerSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTableName))
|
||||
innerSB.Where(
|
||||
innerSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
innerSB.GE("unix_milli", adjustedStart),
|
||||
innerSB.L("unix_milli", adjustedEnd),
|
||||
fmt.Sprintf("fingerprint IN (%s)", innerSB.Var(fpSB)),
|
||||
)
|
||||
|
||||
// Apply optional filter expression
|
||||
if filter != nil && strings.TrimSpace(filter.Expression) != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, filter, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filterClause != nil {
|
||||
innerSB.AddWhereClause(filterClause)
|
||||
}
|
||||
}
|
||||
|
||||
groupByAliases := make([]string, 0, len(groupByCols))
|
||||
for _, col := range groupByCols {
|
||||
groupByAliases = append(groupByAliases, quoteIdentifier(col))
|
||||
}
|
||||
innerSB.GroupBy(groupByAliases...)
|
||||
|
||||
innerQuery, innerArgs := innerSB.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// --- Build outer query ---
|
||||
// Outer SELECT columns: groupBy cols directly + tupleElement(latest_attrs, N) for each additionalCol
|
||||
outerSelectCols := make([]string, 0, len(allCols))
|
||||
for _, col := range groupByCols {
|
||||
outerSelectCols = append(outerSelectCols, quoteIdentifier(col))
|
||||
}
|
||||
for i, col := range additionalCols {
|
||||
outerSelectCols = append(outerSelectCols,
|
||||
fmt.Sprintf("tupleElement(latest_attrs, %d) AS %s", i+1, quoteIdentifier(col)),
|
||||
)
|
||||
}
|
||||
|
||||
outerSB := sqlbuilder.NewSelectBuilder()
|
||||
outerSB.Select(outerSelectCols...)
|
||||
outerSB.From(fmt.Sprintf("(%s)", innerQuery))
|
||||
|
||||
outerQuery, _ := outerSB.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
// All ? params are in innerArgs; outer query introduces none of its own.
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, outerQuery, innerArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]map[string]string)
|
||||
|
||||
for rows.Next() {
|
||||
row := make([]string, len(allCols))
|
||||
scanPtrs := make([]any, len(row))
|
||||
for i := range row {
|
||||
scanPtrs[i] = &row[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(scanPtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
compositeKey := compositeKeyFromList(row[:len(groupByCols)])
|
||||
|
||||
attrMap := make(map[string]string, len(allCols))
|
||||
for i, col := range allCols {
|
||||
attrMap[col] = row[i]
|
||||
}
|
||||
result[compositeKey] = attrMap
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
283
pkg/modules/inframonitoring/implinframonitoring/helpers_test.go
Normal file
283
pkg/modules/inframonitoring/implinframonitoring/helpers_test.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
func groupByKey(name string) qbtypes.GroupByKey {
|
||||
return qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsKeyInGroupByAttrs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
groupByAttrs []qbtypes.GroupByKey
|
||||
key string
|
||||
expectedFound bool
|
||||
}{
|
||||
{
|
||||
name: "key present in single-element list",
|
||||
groupByAttrs: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
key: "host.name",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "key present in multi-element list",
|
||||
groupByAttrs: []qbtypes.GroupByKey{
|
||||
groupByKey("host.name"),
|
||||
groupByKey("os.type"),
|
||||
groupByKey("k8s.cluster.name"),
|
||||
},
|
||||
key: "os.type",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "key at last position",
|
||||
groupByAttrs: []qbtypes.GroupByKey{
|
||||
groupByKey("host.name"),
|
||||
groupByKey("os.type"),
|
||||
},
|
||||
key: "os.type",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "key not in list",
|
||||
groupByAttrs: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
key: "os.type",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "empty group by list",
|
||||
groupByAttrs: []qbtypes.GroupByKey{},
|
||||
key: "host.name",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "nil group by list",
|
||||
groupByAttrs: nil,
|
||||
key: "host.name",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "empty key string",
|
||||
groupByAttrs: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
key: "",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "empty key matches empty-named group by key",
|
||||
groupByAttrs: []qbtypes.GroupByKey{groupByKey("")},
|
||||
key: "",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "partial match does not count",
|
||||
groupByAttrs: []qbtypes.GroupByKey{
|
||||
groupByKey("host"),
|
||||
},
|
||||
key: "host.name",
|
||||
expectedFound: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isKeyInGroupByAttrs(tt.groupByAttrs, tt.key)
|
||||
if got != tt.expectedFound {
|
||||
t.Errorf("isKeyInGroupByAttrs(%v, %q) = %v, want %v",
|
||||
tt.groupByAttrs, tt.key, got, tt.expectedFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeFilterExpressions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
queryFilterExpr string
|
||||
reqFilterExpr string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "both non-empty",
|
||||
queryFilterExpr: "cpu > 50",
|
||||
reqFilterExpr: "host.name = 'web-1'",
|
||||
expected: "(cpu > 50) AND (host.name = 'web-1')",
|
||||
},
|
||||
{
|
||||
name: "query empty, req non-empty",
|
||||
queryFilterExpr: "",
|
||||
reqFilterExpr: "host.name = 'web-1'",
|
||||
expected: "host.name = 'web-1'",
|
||||
},
|
||||
{
|
||||
name: "query non-empty, req empty",
|
||||
queryFilterExpr: "cpu > 50",
|
||||
reqFilterExpr: "",
|
||||
expected: "cpu > 50",
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
queryFilterExpr: "",
|
||||
reqFilterExpr: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only query treated as empty",
|
||||
queryFilterExpr: " ",
|
||||
reqFilterExpr: "host.name = 'web-1'",
|
||||
expected: "host.name = 'web-1'",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only req treated as empty",
|
||||
queryFilterExpr: "cpu > 50",
|
||||
reqFilterExpr: " ",
|
||||
expected: "cpu > 50",
|
||||
},
|
||||
{
|
||||
name: "both whitespace-only",
|
||||
queryFilterExpr: " ",
|
||||
reqFilterExpr: " ",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "leading/trailing whitespace trimmed before merge",
|
||||
queryFilterExpr: " cpu > 50 ",
|
||||
reqFilterExpr: " mem < 80 ",
|
||||
expected: "(cpu > 50) AND (mem < 80)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := mergeFilterExpressions(tt.queryFilterExpr, tt.reqFilterExpr)
|
||||
if got != tt.expected {
|
||||
t.Errorf("mergeFilterExpressions(%q, %q) = %q, want %q",
|
||||
tt.queryFilterExpr, tt.reqFilterExpr, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeKeyFromList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parts []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single part",
|
||||
parts: []string{"web-1"},
|
||||
expected: "web-1",
|
||||
},
|
||||
{
|
||||
name: "multiple parts joined with null separator",
|
||||
parts: []string{"web-1", "linux", "us-east"},
|
||||
expected: "web-1\x00linux\x00us-east",
|
||||
},
|
||||
{
|
||||
name: "empty slice returns empty string",
|
||||
parts: []string{},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "nil slice returns empty string",
|
||||
parts: nil,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "parts with empty strings",
|
||||
parts: []string{"web-1", "", "us-east"},
|
||||
expected: "web-1\x00\x00us-east",
|
||||
},
|
||||
{
|
||||
name: "all empty strings",
|
||||
parts: []string{"", ""},
|
||||
expected: "\x00",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := compositeKeyFromList(tt.parts)
|
||||
if got != tt.expected {
|
||||
t.Errorf("compositeKeyFromList(%v) = %q, want %q",
|
||||
tt.parts, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeKeyFromLabels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
groupBy []qbtypes.GroupByKey
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single group-by key",
|
||||
labels: map[string]string{"host.name": "web-1"},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
expected: "web-1",
|
||||
},
|
||||
{
|
||||
name: "multiple group-by keys joined with null separator",
|
||||
labels: map[string]string{
|
||||
"host.name": "web-1",
|
||||
"os.type": "linux",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name"), groupByKey("os.type")},
|
||||
expected: "web-1\x00linux",
|
||||
},
|
||||
{
|
||||
name: "missing label yields empty segment",
|
||||
labels: map[string]string{"host.name": "web-1"},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name"), groupByKey("os.type")},
|
||||
expected: "web-1\x00",
|
||||
},
|
||||
{
|
||||
name: "empty labels map",
|
||||
labels: map[string]string{},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty group-by slice",
|
||||
labels: map[string]string{"host.name": "web-1"},
|
||||
groupBy: []qbtypes.GroupByKey{},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "nil labels map",
|
||||
labels: nil,
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "order matches group-by order, not map iteration order",
|
||||
labels: map[string]string{
|
||||
"z": "last",
|
||||
"a": "first",
|
||||
"m": "middle",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("a"), groupByKey("m"), groupByKey("z")},
|
||||
expected: "first\x00middle\x00last",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := compositeKeyFromLabels(tt.labels, tt.groupBy)
|
||||
if got != tt.expected {
|
||||
t.Errorf("compositeKeyFromLabels(%v, %v) = %q, want %q",
|
||||
tt.labels, tt.groupBy, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
354
pkg/modules/inframonitoring/implinframonitoring/hosts.go
Normal file
354
pkg/modules/inframonitoring/implinframonitoring/hosts.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// getPerGroupHostStatusCounts computes the number of active and inactive hosts per group
|
||||
// for the current page. It queries the timeseries table using the provided filter
|
||||
// expression (which includes user filter + status filter + page groups IN clause).
|
||||
// Uses GLOBAL IN with the active-hosts subquery inside uniqExactIf for active count,
|
||||
// and a simple uniqExactIf for total count. Inactive = total - active (computed in Go).
|
||||
func (m *module) getPerGroupHostStatusCounts(
|
||||
ctx context.Context,
|
||||
req *inframonitoringtypes.PostableHosts,
|
||||
metricNames []string,
|
||||
pageGroups []map[string]string,
|
||||
sinceUnixMilli int64,
|
||||
) (map[string]groupHostStatusCounts, error) {
|
||||
|
||||
// Build the full filter expression from req (user filter + status filter) and page groups.
|
||||
reqFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
reqFilterExpr = req.Filter.Expression
|
||||
}
|
||||
pageGroupsFilterExpr := buildPageGroupsFilterExpr(pageGroups)
|
||||
filterExpr := mergeFilterExpressions(reqFilterExpr, pageGroupsFilterExpr)
|
||||
|
||||
adjustedStart, adjustedEnd, distributedTimeSeriesTableName, _ := telemetrymetrics.WhichTSTableToUse(
|
||||
uint64(req.Start), uint64(req.End), nil,
|
||||
)
|
||||
|
||||
hostNameExpr := fmt.Sprintf("JSONExtractString(labels, '%s')", hostNameAttrKey)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
selectCols := make([]string, 0, len(req.GroupBy)+2)
|
||||
for _, key := range req.GroupBy {
|
||||
selectCols = append(selectCols,
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", sb.Var(key.Name), quoteIdentifier(key.Name)),
|
||||
)
|
||||
}
|
||||
|
||||
activeHostsSQ := m.getActiveHostsQuery(metricNames, hostNameAttrKey, sinceUnixMilli)
|
||||
selectCols = append(selectCols,
|
||||
fmt.Sprintf("uniqExactIf(%s, %s GLOBAL IN (%s)) AS active_host_count", hostNameExpr, hostNameExpr, sb.Var(activeHostsSQ)),
|
||||
fmt.Sprintf("uniqExactIf(%s, %s != '') AS total_host_count", hostNameExpr, hostNameExpr),
|
||||
)
|
||||
|
||||
// Build a fingerprint subquery to restrict to fingerprints with actual sample
|
||||
// data in the original time range (not the wider timeseries table window).
|
||||
fpSB := m.buildSamplesTblFingerprintSubQuery(metricNames, req.Start, req.End)
|
||||
|
||||
sb.Select(selectCols...)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.GE("unix_milli", adjustedStart),
|
||||
sb.L("unix_milli", adjustedEnd),
|
||||
fmt.Sprintf("fingerprint IN (%s)", sb.Var(fpSB)),
|
||||
)
|
||||
|
||||
// Apply the combined filter expression (user filter + status filter + page groups IN).
|
||||
if filterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filterClause != nil {
|
||||
sb.AddWhereClause(filterClause)
|
||||
}
|
||||
}
|
||||
|
||||
// GROUP BY
|
||||
groupByAliases := make([]string, 0, len(req.GroupBy))
|
||||
for _, key := range req.GroupBy {
|
||||
groupByAliases = append(groupByAliases, quoteIdentifier(key.Name))
|
||||
}
|
||||
sb.GroupBy(groupByAliases...)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]groupHostStatusCounts)
|
||||
for rows.Next() {
|
||||
groupVals := make([]string, len(req.GroupBy))
|
||||
scanPtrs := make([]any, 0, len(req.GroupBy)+2)
|
||||
for i := range groupVals {
|
||||
scanPtrs = append(scanPtrs, &groupVals[i])
|
||||
}
|
||||
|
||||
var activeCount, totalCount uint64
|
||||
scanPtrs = append(scanPtrs, &activeCount, &totalCount)
|
||||
|
||||
if err := rows.Scan(scanPtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
compositeKey := compositeKeyFromList(groupVals)
|
||||
result[compositeKey] = groupHostStatusCounts{
|
||||
Active: int(activeCount),
|
||||
Inactive: int(totalCount - activeCount),
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildHostRecords constructs the final list of HostRecords for a page.
|
||||
// Groups that had no metric data get default values of -1.
|
||||
//
|
||||
// hostCounts is nil when host.name is in the groupBy — in that case, counts are
|
||||
// derived directly from activeHostsMap (1/0 per host). When non-nil (custom groupBy
|
||||
// without host.name), counts are looked up from the map.
|
||||
func buildHostRecords(
|
||||
isHostNameInGroupBy bool,
|
||||
resp *qbtypes.QueryRangeResponse,
|
||||
pageGroups []map[string]string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
metadataMap map[string]map[string]string,
|
||||
activeHostsMap map[string]bool,
|
||||
hostCounts map[string]groupHostStatusCounts,
|
||||
) []inframonitoringtypes.HostRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
|
||||
records := make([]inframonitoringtypes.HostRecord, 0, len(pageGroups))
|
||||
for _, labels := range pageGroups {
|
||||
compositeKey := compositeKeyFromLabels(labels, groupBy)
|
||||
hostName := labels[hostNameAttrKey]
|
||||
|
||||
activeStatus := inframonitoringtypes.HostStatusNone
|
||||
activeHostCount := 0
|
||||
inactiveHostCount := 0
|
||||
if isHostNameInGroupBy { // derive from activeHostsMap since each row = one host
|
||||
if hostName != "" {
|
||||
if activeHostsMap[hostName] {
|
||||
activeStatus = inframonitoringtypes.HostStatusActive
|
||||
activeHostCount = 1
|
||||
} else {
|
||||
activeStatus = inframonitoringtypes.HostStatusInactive
|
||||
inactiveHostCount = 1
|
||||
}
|
||||
}
|
||||
} else { // derive from hostCounts since custom groupBy without host.name
|
||||
if counts, ok := hostCounts[compositeKey]; ok {
|
||||
activeHostCount = counts.Active
|
||||
inactiveHostCount = counts.Inactive
|
||||
}
|
||||
}
|
||||
|
||||
record := inframonitoringtypes.HostRecord{
|
||||
HostName: hostName,
|
||||
Status: activeStatus,
|
||||
ActiveHostCount: activeHostCount,
|
||||
InactiveHostCount: inactiveHostCount,
|
||||
CPU: -1,
|
||||
Memory: -1,
|
||||
Wait: -1,
|
||||
Load15: -1,
|
||||
DiskUsage: -1,
|
||||
Meta: map[string]any{},
|
||||
}
|
||||
|
||||
if metrics, ok := metricsMap[compositeKey]; ok {
|
||||
if v, exists := metrics["F1"]; exists {
|
||||
record.CPU = v
|
||||
}
|
||||
if v, exists := metrics["F2"]; exists {
|
||||
record.Memory = v
|
||||
}
|
||||
if v, exists := metrics["F3"]; exists {
|
||||
record.Wait = v
|
||||
}
|
||||
if v, exists := metrics["F4"]; exists {
|
||||
record.DiskUsage = v
|
||||
}
|
||||
if v, exists := metrics["G"]; exists {
|
||||
record.Load15 = v
|
||||
}
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
for k, v := range attrs {
|
||||
record.Meta[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
// getTopHostGroups runs a ranking query for the ordering metric, sorts the
|
||||
// results, paginates, and backfills from metadataMap when the page extends
|
||||
// past the metric-ranked groups.
|
||||
func (m *module) getTopHostGroups(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableHosts,
|
||||
metadataMap map[string]map[string]string,
|
||||
) ([]map[string]string, error) {
|
||||
orderByKey := req.OrderBy.Key.Name
|
||||
queryNamesForOrderBy := orderByToHostsQueryNames[orderByKey]
|
||||
// The last entry is the formula/query whose value we sort by.
|
||||
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
|
||||
|
||||
topReq := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(req.Start),
|
||||
End: uint64(req.End),
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: make([]qbtypes.QueryEnvelope, 0, len(queryNamesForOrderBy)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, envelope := range m.newListHostsQuery().CompositeQuery.Queries {
|
||||
if !slices.Contains(queryNamesForOrderBy, envelope.GetQueryName()) {
|
||||
continue
|
||||
}
|
||||
copied := envelope
|
||||
if copied.Type == qbtypes.QueryTypeBuilder {
|
||||
existingExpr := ""
|
||||
if f := copied.GetFilter(); f != nil {
|
||||
existingExpr = f.Expression
|
||||
}
|
||||
reqFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
reqFilterExpr = req.Filter.Expression
|
||||
}
|
||||
merged := mergeFilterExpressions(existingExpr, reqFilterExpr)
|
||||
copied.SetFilter(&qbtypes.Filter{Expression: merged})
|
||||
copied.SetGroupBy(req.GroupBy)
|
||||
}
|
||||
topReq.CompositeQuery.Queries = append(topReq.CompositeQuery.Queries, copied)
|
||||
}
|
||||
|
||||
resp, err := m.querier.QueryRange(ctx, orgID, topReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allMetricGroups := parseAndSortGroups(resp, rankingQueryName, req.GroupBy, req.OrderBy.Direction)
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
|
||||
}
|
||||
|
||||
// applyHostsActiveStatusFilter MODIFIES req.Filter.Expression to include an IN/NOT IN
|
||||
// clause based on FilterByStatus and the set of active hosts.
|
||||
// Returns true if the caller should short-circuit with an empty result (eg. ACTIVE
|
||||
// requested but no hosts are active).
|
||||
func (m *module) applyHostsActiveStatusFilter(req *inframonitoringtypes.PostableHosts, activeHostsMap map[string]bool) (shouldShortCircuit bool) {
|
||||
if req.Filter == nil || (req.Filter.FilterByStatus != inframonitoringtypes.HostStatusActive && req.Filter.FilterByStatus != inframonitoringtypes.HostStatusInactive) {
|
||||
return false
|
||||
}
|
||||
|
||||
activeHosts := make([]string, 0, len(activeHostsMap))
|
||||
for host := range activeHostsMap {
|
||||
activeHosts = append(activeHosts, fmt.Sprintf("'%s'", host))
|
||||
}
|
||||
|
||||
if len(activeHosts) == 0 {
|
||||
return req.Filter.FilterByStatus == inframonitoringtypes.HostStatusActive
|
||||
}
|
||||
|
||||
op := "IN"
|
||||
if req.Filter.FilterByStatus == inframonitoringtypes.HostStatusInactive {
|
||||
op = "NOT IN"
|
||||
}
|
||||
statusClause := fmt.Sprintf("%s %s (%s)", hostNameAttrKey, op, strings.Join(activeHosts, ", "))
|
||||
req.Filter.Expression = mergeFilterExpressions(req.Filter.Expression, statusClause)
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *module) getHostsTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableHosts) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range hostAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
var filter *qbtypes.Filter
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
}
|
||||
metadataMap, err := m.getMetadata(ctx, hostsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return metadataMap, nil
|
||||
}
|
||||
|
||||
// getActiveHostsQuery builds a SelectBuilder that returns distinct host names
|
||||
// with metrics reported in the last 10 minutes. The builder is not executed —
|
||||
// callers can either execute it (getActiveHosts) or embed it as a subquery
|
||||
// (getPerGroupActiveInactiveHostCounts).
|
||||
func (m *module) getActiveHostsQuery(metricNames []string, hostNameAttr string, sinceUnixMilli int64) *sqlbuilder.SelectBuilder {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Distinct()
|
||||
sb.Select("attr_string_value")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.E("attr_name", hostNameAttr),
|
||||
sb.NE("attr_string_value", ""),
|
||||
sb.GE("last_reported_unix_milli", sinceUnixMilli),
|
||||
)
|
||||
|
||||
return sb
|
||||
}
|
||||
|
||||
// getActiveHosts returns a set of host names that have reported metrics recently.
|
||||
// It queries distributed_metadata for hosts where last_reported_unix_milli >= sinceUnixMilli.
|
||||
func (m *module) getActiveHosts(ctx context.Context, metricNames []string, hostNameAttr string, sinceUnixMilli int64) (map[string]bool, error) {
|
||||
sb := m.getActiveHostsQuery(metricNames, hostNameAttr, sinceUnixMilli)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
activeHosts := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var hostName string
|
||||
if err := rows.Scan(&hostName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hostName != "" {
|
||||
activeHosts[hostName] = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return activeHosts, nil
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const (
|
||||
hostNameAttrKey = "host.name"
|
||||
)
|
||||
|
||||
// Helper group-by key used across all queries.
|
||||
var hostNameGroupByKey = qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: hostNameAttrKey,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
}
|
||||
|
||||
var hostsTableMetricNamesList = []string{
|
||||
"system.cpu.time",
|
||||
"system.memory.usage",
|
||||
"system.cpu.load_average.15m",
|
||||
"system.filesystem.usage",
|
||||
}
|
||||
|
||||
var hostAttrKeysForMetadata = []string{
|
||||
"os.type",
|
||||
}
|
||||
|
||||
// orderByToHostsQueryNames maps the orderBy column to the query/formula names
|
||||
// from HostsTableListQuery used for ranking host groups.
|
||||
var orderByToHostsQueryNames = map[string][]string{
|
||||
inframonitoringtypes.HostsOrderByCPU: {"A", "B", "F1"},
|
||||
inframonitoringtypes.HostsOrderByMemory: {"C", "D", "F2"},
|
||||
inframonitoringtypes.HostsOrderByWait: {"E", "F", "F3"},
|
||||
inframonitoringtypes.HostsOrderByDiskUsage: {"H", "I", "F4"},
|
||||
inframonitoringtypes.HostsOrderByLoad15: {"G"},
|
||||
}
|
||||
|
||||
// newListHostsQuery constructs the base QueryRangeRequest with all the queries for the hosts table.
|
||||
// This is kept in this file because the queries themselves do not change based on the request parameters
|
||||
// only the filters, group bys, and order bys change, which are applied in buildFullQueryRequest.
|
||||
func (m *module) newListHostsQuery() *qbtypes.QueryRangeRequest {
|
||||
return &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
// Query A: CPU usage logic (non-idle)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.time",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "state != 'idle'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Query B: CPU usage (all states)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.time",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Formula F1: CPU Usage (%)
|
||||
{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A/B",
|
||||
Legend: "CPU Usage (%)",
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query C: Memory usage (state = used)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "C",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.memory.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "state = 'used'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Query D: Memory usage (all states)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "D",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.memory.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Formula F2: Memory Usage (%)
|
||||
{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{
|
||||
Name: "F2",
|
||||
Expression: "C/D",
|
||||
Legend: "Memory Usage (%)",
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query E: CPU Wait time (state = wait)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "E",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.time",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "state = 'wait'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Query F: CPU time (all states)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "F",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.time",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Formula F3: CPU Wait Time (%)
|
||||
{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{
|
||||
Name: "F3",
|
||||
Expression: "E/F",
|
||||
Legend: "CPU Wait Time (%)",
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query G: Load15
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "G",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Legend: "CPU Load Average (15m)",
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.load_average.15m",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query H: Filesystem Usage (state = used)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "H",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.filesystem.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "state = 'used'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Query I: Filesystem Usage (all states)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "I",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.filesystem.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Formula F4: Disk Usage (%)
|
||||
{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{
|
||||
Name: "F4",
|
||||
Expression: "H/I",
|
||||
Legend: "Disk Usage (%)",
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package implinframonitoring
|
||||
|
||||
// The types in this file are only used within the implinframonitoring package, and are not exposed outside.
|
||||
// They are primarily used for internal processing and structuring of data within the module's implementation.
|
||||
|
||||
type rankedGroup struct {
|
||||
labels map[string]string
|
||||
value float64
|
||||
compositeKey string
|
||||
}
|
||||
|
||||
// groupHostStatusCounts holds per-group active and inactive host counts.
|
||||
type groupHostStatusCounts struct {
|
||||
Active int
|
||||
Inactive int
|
||||
}
|
||||
161
pkg/modules/inframonitoring/implinframonitoring/module.go
Normal file
161
pkg/modules/inframonitoring/implinframonitoring/module.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore
|
||||
querier querier.Querier
|
||||
fieldMapper qbtypes.FieldMapper
|
||||
condBuilder qbtypes.ConditionBuilder
|
||||
logger *slog.Logger
|
||||
config inframonitoring.Config
|
||||
}
|
||||
|
||||
// NewModule constructs the inframonitoring module with the provided dependencies.
|
||||
func NewModule(
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore,
|
||||
querier querier.Querier,
|
||||
providerSettings factory.ProviderSettings,
|
||||
cfg inframonitoring.Config,
|
||||
) inframonitoring.Module {
|
||||
fieldMapper := telemetrymetrics.NewFieldMapper()
|
||||
condBuilder := telemetrymetrics.NewConditionBuilder(fieldMapper)
|
||||
return &module{
|
||||
telemetryStore: telemetryStore,
|
||||
telemetryMetadataStore: telemetryMetadataStore,
|
||||
querier: querier,
|
||||
fieldMapper: fieldMapper,
|
||||
condBuilder: condBuilder,
|
||||
logger: providerSettings.Logger,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &inframonitoringtypes.Hosts{}
|
||||
|
||||
// default to cpu order by
|
||||
if req.OrderBy == nil {
|
||||
req.OrderBy = &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "cpu",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
}
|
||||
}
|
||||
|
||||
// default to host name group by
|
||||
if len(req.GroupBy) == 0 {
|
||||
req.GroupBy = []qbtypes.GroupByKey{hostNameGroupByKey}
|
||||
resp.Type = inframonitoringtypes.ResponseTypeList
|
||||
} else {
|
||||
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
|
||||
}
|
||||
|
||||
// 1. Check which required metrics exist and get earliest retention time.
|
||||
// If any required metric is missing, return early with the list of missing metrics.
|
||||
// 2. If metrics exist but req.End is before the earliest reported time, return early with endTimeBeforeRetention=true.
|
||||
missingMetrics, minFirstReportedUnixMilli, err := m.getMetricsExistenceAndEarliestTime(ctx, hostsTableMetricNamesList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(missingMetrics) > 0 {
|
||||
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: missingMetrics}
|
||||
resp.Records = []inframonitoringtypes.HostRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
if req.End < int64(minFirstReportedUnixMilli) {
|
||||
resp.EndTimeBeforeRetention = true
|
||||
resp.Records = []inframonitoringtypes.HostRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: []string{}}
|
||||
|
||||
// TOD(nikhilmantri0902): replace this separate ClickHouse query with a sub-query inside the main query builder query
|
||||
// once QB supports sub-queries.
|
||||
// Determine active hosts: those with metrics reported in the last 10 minutes.
|
||||
// Compute the cutoff once so every downstream query/subquery agrees on what "active" means.
|
||||
sinceUnixMilli := time.Now().Add(-10 * time.Minute).UTC().UnixMilli()
|
||||
activeHostsMap, err := m.getActiveHosts(ctx, hostsTableMetricNamesList, hostNameAttrKey, sinceUnixMilli)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// this check below modifies req.Filter by adding `AND active hosts filter` if req.FilterByStatus is set.
|
||||
if m.applyHostsActiveStatusFilter(req, activeHostsMap) {
|
||||
resp.Records = []inframonitoringtypes.HostRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getHostsTableMetadata(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
pageGroups, err := m.getTopHostGroups(ctx, orgID, req, metadataMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
resp.Records = []inframonitoringtypes.HostRecord{}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
hostsFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
hostsFilterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, hostsFilterExpr, req.GroupBy, pageGroups, m.newListHostsQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Compute per-group active/inactive host counts.
|
||||
// When host.name is in groupBy, each row = one host, so counts are derived
|
||||
// directly from activeHostsMap in buildHostRecords (no extra query needed).
|
||||
// When host.name is not in groupBy, we need to run an additional query to get the counts per group for the current page,
|
||||
// using the same filter expression as the main query (including user filters + page groups IN clause).
|
||||
hostCounts := make(map[string]groupHostStatusCounts)
|
||||
isHostNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, hostNameAttrKey)
|
||||
if !isHostNameInGroupBy {
|
||||
hostCounts, err = m.getPerGroupHostStatusCounts(ctx, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
resp.Records = buildHostRecords(isHostNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, activeHostsMap, hostCounts)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
17
pkg/modules/inframonitoring/inframonitoring.go
Normal file
17
pkg/modules/inframonitoring/inframonitoring.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package inframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Handler interface {
|
||||
ListHosts(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type Module interface {
|
||||
ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error)
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package implspanattributemapping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanattributemapping"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spanattributemappingtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module spanattributemapping.Module
|
||||
providerSettings factory.ProviderSettings
|
||||
}
|
||||
|
||||
func NewHandler(module spanattributemapping.Module, providerSettings factory.ProviderSettings) spanattributemapping.Handler {
|
||||
return &handler{module: module, providerSettings: providerSettings}
|
||||
}
|
||||
|
||||
// ListGroups handles GET /api/v1/span_attribute_mapping_groups.
|
||||
func (h *handler) ListGroups(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var q spanattributemappingtypes.ListGroupsQuery
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), &q); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
groups, err := h.module.ListGroups(ctx, orgID, &q)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, spanattributemappingtypes.NewGettableGroups(groups))
|
||||
}
|
||||
|
||||
// CreateGroup handles POST /api/v1/span_attribute_mapping_groups.
|
||||
func (h *handler) CreateGroup(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(spanattributemappingtypes.PostableGroup)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
group := spanattributemappingtypes.NewGroupFromPostable(req)
|
||||
|
||||
err = h.module.CreateGroup(ctx, orgID, claims.Email, group)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
render.Success(rw, http.StatusCreated, group)
|
||||
}
|
||||
|
||||
// UpdateGroup handles PUT /api/v1/span_attribute_mapping_groups/{id}.
|
||||
func (h *handler) UpdateGroup(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := groupIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(spanattributemappingtypes.UpdatableGroup)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.module.UpdateGroup(ctx, orgID, id, claims.Email, spanattributemappingtypes.NewGroupFromUpdatable(req))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// DeleteGroup handles DELETE /api/v1/span_attribute_mapping_groups/{id}.
|
||||
func (h *handler) DeleteGroup(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := groupIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.module.DeleteGroup(ctx, orgID, id); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// ListMappers handles GET /api/v1/span_attribute_mapping_groups/{id}/mappers.
|
||||
func (h *handler) ListMappers(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
groupID, err := groupIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
mappers, err := h.module.ListMappers(ctx, orgID, groupID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, spanattributemappingtypes.NewGettableMappers(mappers))
|
||||
}
|
||||
|
||||
// CreateMapper handles POST /api/v1/span_attribute_mapping_groups/{id}/mappers.
|
||||
func (h *handler) CreateMapper(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
groupID, err := groupIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(spanattributemappingtypes.PostableMapper)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
mapper := spanattributemappingtypes.NewMapperFromPostable(req)
|
||||
|
||||
err = h.module.CreateMapper(ctx, orgID, groupID, claims.Email, mapper)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, mapper)
|
||||
}
|
||||
|
||||
// UpdateMapper handles PUT /api/v1/span_attribute_mapping_groups/{groupId}/mappers/{mapperId}.
|
||||
func (h *handler) UpdateMapper(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
groupID, err := groupIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
mapperID, err := mapperIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(spanattributemappingtypes.UpdatableMapper)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.module.UpdateMapper(ctx, orgID, groupID, mapperID, claims.Email, spanattributemappingtypes.NewMapperFromUpdatable(req))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// DeleteMapper handles DELETE /api/v1/span_attribute_mapping_groups/{groupId}/mappers/{mapperId}.
|
||||
func (h *handler) DeleteMapper(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
groupID, err := groupIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
mapperID, err := mapperIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.module.DeleteMapper(ctx, orgID, groupID, mapperID); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// groupIDFromPath extracts and validates the {id} or {groupId} path variable.
|
||||
func groupIDFromPath(r *http.Request) (valuer.UUID, error) {
|
||||
vars := mux.Vars(r)
|
||||
raw := vars["groupId"]
|
||||
id, err := valuer.NewUUID(raw)
|
||||
if err != nil {
|
||||
return valuer.UUID{}, errors.Wrapf(err, errors.TypeInvalidInput, spanattributemappingtypes.ErrCodeMappingInvalidInput, "group id is not a valid uuid")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// mapperIDFromPath extracts and validates the {mapperId} path variable.
|
||||
func mapperIDFromPath(r *http.Request) (valuer.UUID, error) {
|
||||
raw := mux.Vars(r)["mapperId"]
|
||||
id, err := valuer.NewUUID(raw)
|
||||
if err != nil {
|
||||
return valuer.UUID{}, errors.Wrapf(err, errors.TypeInvalidInput, spanattributemappingtypes.ErrCodeMappingInvalidInput, "mapper id is not a valid uuid")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
41
pkg/modules/spanattributemapping/spanattributemapping.go
Normal file
41
pkg/modules/spanattributemapping/spanattributemapping.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package spanattributemapping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/spanattributemappingtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// Module defines the business logic for span attribute mapping groups and mappers.
|
||||
type Module interface {
|
||||
// Group operations
|
||||
ListGroups(ctx context.Context, orgID valuer.UUID, q *spanattributemappingtypes.ListGroupsQuery) ([]*spanattributemappingtypes.Group, error)
|
||||
GetGroup(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*spanattributemappingtypes.Group, error)
|
||||
CreateGroup(ctx context.Context, orgID valuer.UUID, createdBy string, group *spanattributemappingtypes.Group) error
|
||||
UpdateGroup(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, group *spanattributemappingtypes.Group) error
|
||||
DeleteGroup(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
// Mapper operations
|
||||
ListMappers(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID) ([]*spanattributemappingtypes.Mapper, error)
|
||||
GetMapper(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID, id valuer.UUID) (*spanattributemappingtypes.Mapper, error)
|
||||
CreateMapper(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID, createdBy string, mapper *spanattributemappingtypes.Mapper) error
|
||||
UpdateMapper(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID, id valuer.UUID, updatedBy string, mapper *spanattributemappingtypes.Mapper) error
|
||||
DeleteMapper(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID, id valuer.UUID) error
|
||||
}
|
||||
|
||||
// Handler defines the HTTP handler interface for mapping group and mapper endpoints.
|
||||
type Handler interface {
|
||||
// Group handlers
|
||||
ListGroups(rw http.ResponseWriter, r *http.Request)
|
||||
CreateGroup(rw http.ResponseWriter, r *http.Request)
|
||||
UpdateGroup(rw http.ResponseWriter, r *http.Request)
|
||||
DeleteGroup(rw http.ResponseWriter, r *http.Request)
|
||||
|
||||
// Mapper handlers
|
||||
ListMappers(rw http.ResponseWriter, r *http.Request)
|
||||
CreateMapper(rw http.ResponseWriter, r *http.Request)
|
||||
UpdateMapper(rw http.ResponseWriter, r *http.Request)
|
||||
DeleteMapper(rw http.ResponseWriter, r *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