Compare commits
7 Commits
feat/semco
...
test/e2e-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff05357ea0 | ||
|
|
908f421dd8 | ||
|
|
762ea483db | ||
|
|
6d60ea0c5e | ||
|
|
dc248794d9 | ||
|
|
bd2e0aaa18 | ||
|
|
5a210c114d |
15
.github/workflows/goci.yaml
vendored
@@ -53,21 +53,6 @@ jobs:
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
semconv-generated:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: check-semconv-generated-files
|
||||
run: go run ./scripts/semconv -check
|
||||
build:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
|
||||
4
Makefile
@@ -233,10 +233,6 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
|
||||
##############################################################
|
||||
# generate commands
|
||||
##############################################################
|
||||
.PHONY: semconv-generate
|
||||
semconv-generate: ## Regenerate semantic-convention families for Go and TypeScript
|
||||
@go run ./scripts/semconv
|
||||
|
||||
.PHONY: gen-mocks
|
||||
gen-mocks:
|
||||
@echo ">> Generating mocks"
|
||||
|
||||
@@ -10247,9 +10247,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- ADMIN
|
||||
summary: Delete public dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -10298,9 +10298,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:read
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- dashboard:read
|
||||
- ADMIN
|
||||
summary: Get public dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -10355,9 +10355,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- ADMIN
|
||||
summary: Create public dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -10399,9 +10399,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- ADMIN
|
||||
summary: Update public dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -14739,9 +14739,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
summary: List dashboard saved views
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -14796,9 +14796,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- EDITOR
|
||||
summary: Create dashboard saved view
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -14849,9 +14849,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- EDITOR
|
||||
summary: Delete dashboard saved view
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -14917,9 +14917,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- EDITOR
|
||||
summary: Update dashboard saved view
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -14994,9 +14994,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
summary: List dashboards (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15051,9 +15051,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:create
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:create
|
||||
- EDITOR
|
||||
summary: Create dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15104,9 +15104,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:delete
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:delete
|
||||
- EDITOR
|
||||
summary: Delete dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15167,9 +15167,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- dashboard:read
|
||||
- VIEWER
|
||||
summary: Get dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15241,9 +15241,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
summary: Patch dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15310,9 +15310,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
summary: Update dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15378,11 +15378,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:read
|
||||
- dashboard:create
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:read
|
||||
- dashboard:create
|
||||
- EDITOR
|
||||
summary: Clone dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15433,9 +15431,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
summary: Unlock dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15485,9 +15483,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
summary: Lock dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15551,9 +15549,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
summary: Migrate dashboard to v2
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -23697,9 +23695,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
summary: List dashboards for the current user (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -23744,9 +23742,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
summary: Unpin a dashboard for the current user (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -23803,9 +23801,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
summary: Pin a dashboard for the current user (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="#9CA3AF" fill-rule="evenodd" style="flex:none;line-height:1" viewBox="0 0 24 24"><title>AWS</title><path d="M6.763 11.212q.002.446.088.71c.064.176.144.368.256.576.04.063.056.127.056.183q.002.12-.152.24l-.503.335a.4.4 0 0 1-.208.072q-.12-.002-.239-.112a2.5 2.5 0 0 1-.287-.375 6 6 0 0 1-.248-.471q-.934 1.101-2.347 1.101c-.67 0-1.205-.191-1.596-.574-.39-.384-.59-.894-.59-1.533 0-.678.24-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583q-.001-.908-.375-1.277c-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103s-.583.16-.862.272a2 2 0 0 1-.28.104.5.5 0 0 1-.127.023q-.168.002-.168-.247v-.391c0-.128.016-.224.056-.28a.6.6 0 0 1 .224-.167 4.6 4.6 0 0 1 1.005-.36 4.8 4.8 0 0 1 1.246-.151c.95 0 1.644.216 2.091.647q.661.646.662 1.963v2.586zm-3.24 1.214c.263 0 .534-.048.822-.144a1.8 1.8 0 0 0 .758-.51 1.3 1.3 0 0 0 .272-.512c.047-.191.08-.423.08-.694v-.335a7 7 0 0 0-.735-.136 6 6 0 0 0-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296m6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 6.726a1.4 1.4 0 0 1-.072-.32c0-.128.064-.2.191-.2h.783q.227-.001.31.08c.065.048.113.16.16.312l1.342 5.284 1.245-5.284q.058-.24.151-.312a.55.55 0 0 1 .32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348q.074-.24.16-.312a.52.52 0 0 1 .311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1 1 0 0 1-.056.2l-1.923 6.17q-.072.24-.168.311a.5.5 0 0 1-.303.08h-.687c-.15 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32L12.32 7.747l-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.6.6 0 0 1-.048-.224v-.407c0-.167.064-.247.183-.247q.072 0 .144.024c.048.016.12.048.2.08q.408.181.878.279c.32.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 0 0 .415-.758.78.78 0 0 0-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.9 1.9 0 0 1-.4-1.158q0-.502.216-.886c.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .36.008.535.032.183.024.35.056.518.088q.24.058.455.127.216.072.336.144a.7.7 0 0 1 .24.2.43.43 0 0 1 .071.263v.375q-.002.254-.184.256a.8.8 0 0 1-.303-.096 3.65 3.65 0 0 0-1.532-.311c-.455 0-.815.071-1.062.223s-.375.383-.375.71c0 .224.08.416.24.567.16.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767s.367.702.367 1.117c0 .343-.072.655-.207.926a2.2 2.2 0 0 1-.583.703c-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167"/><path fill="#f90" d="M.378 15.475c3.384 1.963 7.56 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.44-.2.814.287.383.607-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351m23.531-.2c.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151l.175-.439c.343-.88.802-2.198.52-2.555-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" fill-rule="evenodd" style="flex:none;line-height:1" viewBox="0 0 24 24"><title>AWS</title><path d="M6.763 11.212q.002.446.088.71c.064.176.144.368.256.576.04.063.056.127.056.183q.002.12-.152.24l-.503.335a.4.4 0 0 1-.208.072q-.12-.002-.239-.112a2.5 2.5 0 0 1-.287-.375 6 6 0 0 1-.248-.471q-.934 1.101-2.347 1.101c-.67 0-1.205-.191-1.596-.574-.39-.384-.59-.894-.59-1.533 0-.678.24-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583q-.001-.908-.375-1.277c-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103s-.583.16-.862.272a2 2 0 0 1-.28.104.5.5 0 0 1-.127.023q-.168.002-.168-.247v-.391c0-.128.016-.224.056-.28a.6.6 0 0 1 .224-.167 4.6 4.6 0 0 1 1.005-.36 4.8 4.8 0 0 1 1.246-.151c.95 0 1.644.216 2.091.647q.661.646.662 1.963v2.586zm-3.24 1.214c.263 0 .534-.048.822-.144a1.8 1.8 0 0 0 .758-.51 1.3 1.3 0 0 0 .272-.512c.047-.191.08-.423.08-.694v-.335a7 7 0 0 0-.735-.136 6 6 0 0 0-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296m6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 6.726a1.4 1.4 0 0 1-.072-.32c0-.128.064-.2.191-.2h.783q.227-.001.31.08c.065.048.113.16.16.312l1.342 5.284 1.245-5.284q.058-.24.151-.312a.55.55 0 0 1 .32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348q.074-.24.16-.312a.52.52 0 0 1 .311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1 1 0 0 1-.056.2l-1.923 6.17q-.072.24-.168.311a.5.5 0 0 1-.303.08h-.687c-.15 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32L12.32 7.747l-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.6.6 0 0 1-.048-.224v-.407c0-.167.064-.247.183-.247q.072 0 .144.024c.048.016.12.048.2.08q.408.181.878.279c.32.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 0 0 .415-.758.78.78 0 0 0-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.9 1.9 0 0 1-.4-1.158q0-.502.216-.886c.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .36.008.535.032.183.024.35.056.518.088q.24.058.455.127.216.072.336.144a.7.7 0 0 1 .24.2.43.43 0 0 1 .071.263v.375q-.002.254-.184.256a.8.8 0 0 1-.303-.096 3.65 3.65 0 0 0-1.532-.311c-.455 0-.815.071-1.062.223s-.375.383-.375.71c0 .224.08.416.24.567.16.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767s.367.702.367 1.117c0 .343-.072.655-.207.926a2.2 2.2 0 0 1-.583.703c-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167"/><path fill="#f90" d="M.378 15.475c3.384 1.963 7.56 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.44-.2.814.287.383.607-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351m23.531-.2c.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151l.175-.439c.343-.88.802-2.198.52-2.555-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#29F1FB" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
|
||||
<path fill="currentColor" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#D22128" d="M17.805 2.197v.066h.156v.44h.072v-.44h.156v-.066zm.9 0l-.175.353-.172-.353h-.087v.506h.067V2.3l.172.35h.045l.172-.35v.404h.066v-.506zm-4.257 1c-.204.31-.424.66-.66 1.06l-.04.062a44.457 44.457 0 00-1.265 2.29c-.187.36-.38.742-.577 1.146l2.267-.25c.66-.302.955-.578 1.242-.976a15.5 15.5 0 00.23-.342c.23-.363.46-.763.663-1.16.197-.386.37-.767.505-1.11.083-.22.15-.422.198-.6.042-.158.074-.307.1-.45-.884.15-1.965.295-2.668.33zM11.894 7.78l-.077.16c-.078.16-.157.32-.236.488-.086.18-.172.364-.26.552l-.132.287a75.265 75.265 0 00-1.427 3.3c-.163.397-.327.807-.493 1.23-.15.38-.297.765-.45 1.164l-.02.06c-.15.396-.3.802-.453 1.22l-.01.027.72-.08a.213.213 0 01-.042-.006c.863-.106 2.01-.75 2.75-1.547.342-.367.652-.8.94-1.306.213-.377.413-.795.604-1.258.168-.405.328-.843.48-1.318-.196.105-.423.18-.673.235a2.184 2.184 0 01-.273.046c.806-.31 1.314-.905 1.683-1.64a2.816 2.816 0 01-.968.428c-.06.012-.116.022-.174.03l-.043.006h.002c.278-.118.514-.248.718-.403a2.571 2.571 0 00.637-.698l.063-.104.077-.154a8.107 8.107 0 00.367-.85l.03-.088a3.04 3.04 0 00.123-.463.733.733 0 01-.094.065c-.243.145-.66.277-.996.34l.663-.074-.664.073h-.017l-.1.017c.006-.003.01-.006.017-.008l-2.265.25-.013.022zM8.27 16.45c-.117.323-.236.654-.355.992l-.005.015c-.016.046-.032.094-.05.142-.08.227-.15.432-.31.9.264.12.475.435.675.793a1.44 1.44 0 00-.466-.99c1.293.06 2.41-.27 2.99-1.217.05-.084.096-.173.14-.268-.26.333-.59.474-1.2.44 0 0-.004 0-.005.002l.004-.002c.9-.404 1.354-.79 1.754-1.433.094-.153.186-.32.28-.503-.788.81-1.702 1.04-2.664.865l-.72.078a6.43 6.43 0 00-.067.183zM15.42.112c-.376.222-1 .85-1.748 1.763l.686 1.294c.48-.687.97-1.307 1.462-1.836l.058-.062c-.02.02-.04.04-.057.062-.16.176-.644.74-1.375 1.863.703-.035 1.784-.18 2.666-.33.262-1.47-.258-2.142-.258-2.142s-.66-1.07-1.436-.61zm-3.084 6.402a40.253 40.253 0 011.306-2.26l.04-.064c.224-.352.45-.693.677-1.02l-.685-1.293-.157.192c-.197.245-.403.51-.613.79a39.853 39.853 0 00-2.016 2.97l-.022.038.893 1.763c.19-.378.38-.752.575-1.118zm-3.73 8.32c.158-.406.319-.81.483-1.225.156-.394.32-.79.484-1.19a91.133 91.133 0 011.6-3.604l.205-.424c.12-.243.237-.485.36-.724a.125.125 0 01.02-.04l-.895-1.763-.044.07c-.207.34-.414.687-.617 1.042a38.056 38.056 0 00-1.092 2.04l-.094.193a24.573 24.573 0 00-1.258 3.087 18.492 18.492 0 00-.52 1.997l.896 1.77c.117-.317.24-.638.364-.963zm-1.376-.476a13.38 13.38 0 00-.234 1.692c0 .02-.004.04-.005.06-.28-.45-1.03-.888-1.026-.884.537.778.944 1.55 1.005 2.31-.29.058-.684-.027-1.14-.195.475.436.83.556.97.588-.434.03-.89.328-1.346.67.668-.27 1.21-.38 1.596-.29-.61 1.74-1.23 3.655-1.843 5.69a.538.538 0 00.364-.354c.11-.368.84-2.786 1.978-5.965l.097-.27.028-.078c.12-.332.246-.672.374-1.02l.09-.237v-.004L7.24 14.3c-.003.02-.01.04-.012.06z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#EB5424" d="M21.98 7.448 19.62 0H4.347L2.02 7.448c-1.352 4.312.03 9.206 3.815 12.015L12.007 24l6.157-4.552c3.755-2.81 5.182-7.688 3.815-12.015l-6.16 4.58 2.343 7.45-6.157-4.597-6.158 4.58 2.358-7.433-6.188-4.55 7.63-.045L12.008 0l2.356 7.404 7.615.044z"/></svg>
|
||||
|
Before Width: | Height: | Size: 333 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#9CA3AF" height="34" viewBox="0 0 131 34">
|
||||
<path fill="#9CA3AF" d="M.36 8.6h16.7v5.6H6.04c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h11.02v5.6h-5.33c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h4.98c.2 0 .35-.15.35-.35V25.4h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34v-5.6h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34V3.35c0-.2-.16-.35-.35-.35H.36c-.2 0-.36.16-.36.35v4.9c0 .2.16.35.36.35ZM44.41 14.7c-.5-.5-1.1-.9-1.76-1.18a5.62 5.62 0 0 0-4.6.17c-.73.37-1.32.91-1.75 1.62h-.17V8.59H34.1v16.83h2.04v-1.81h.17c.21.36.47.67.77.94.31.25.65.48 1.01.67.37.18.77.31 1.18.39a6.2 6.2 0 0 0 3.39-.24 5.36 5.36 0 0 0 3.02-3.1c.29-.75.44-1.62.44-2.6v-.47c0-.96-.16-1.83-.47-2.58-.3-.75-.7-1.4-1.23-1.9v-.01Zm-5.87.66a3.9 3.9 0 0 1 4.34.84c.36.35.64.8.83 1.3.2.5.3 1.07.3 1.7v.47c0 .64-.1 1.23-.3 1.74a3.75 3.75 0 0 1-2.06 2.15 4.27 4.27 0 0 1-3.12-.03 3.86 3.86 0 0 1-2.09-2.2c-.2-.52-.3-1.11-.3-1.75v-.29c0-.62.1-1.2.3-1.7v-.01c.21-.53.5-.99.84-1.36.36-.37.78-.66 1.26-.86ZM97.04 8.59H95v4.86h-2.94v1.86H95v8.17c0 .56.17 1.03.53 1.4.37.35.84.54 1.4.54h4.18v-1.87h-3.5c-.2 0-.33-.05-.43-.15-.1-.1-.14-.27-.14-.49v-7.6h4.65v-1.86h-4.65V8.59ZM114.61 15a5.48 5.48 0 0 0-1.8-1.33 5.6 5.6 0 0 0-2.57-.56 6.17 6.17 0 0 0-4.26 1.7 5.6 5.6 0 0 0-1.72 4.2v.57c0 .9.15 1.75.44 2.5a5.58 5.58 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.35 5.35 0 0 0 1.91-2.73l.03-.07-1.94-.52-.02.07c-.11.33-.27.64-.46.94-.17.27-.4.52-.7.74-.28.22-.63.39-1.04.51-.41.13-.9.19-1.46.19a3.8 3.8 0 0 1-2.84-1.05 4.07 4.07 0 0 1-1.1-2.7h9.68v-1.6c0-.54-.11-1.12-.34-1.75a5.04 5.04 0 0 0-1.03-1.74Zm-8.25 3.21a3.8 3.8 0 0 1 1.22-2.25 4.19 4.19 0 0 1 3.99-.7c.44.16.83.38 1.17.66.34.27.62.62.82 1.02.21.38.34.8.38 1.27h-7.58ZM129.09 14.42a4.47 4.47 0 0 0-3.37-1.3c-.93 0-1.73.2-2.4.59-.64.39-1.15.97-1.52 1.74h-.17v-2h-2.04v11.97h2.04v-6.23c0-1.26.32-2.28.95-3.02a3.31 3.31 0 0 1 2.65-1.14c.94 0 1.7.3 2.24.9.56.6.83 1.52.83 2.74v6.75h2.04v-7.13c0-1.71-.42-3.02-1.25-3.87ZM88.1 15a5.48 5.48 0 0 0-1.78-1.33 5.6 5.6 0 0 0-2.58-.56 6.17 6.17 0 0 0-4.27 1.7 5.59 5.59 0 0 0-1.71 4.2v.56c0 .92.14 1.76.44 2.51a5.6 5.6 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.36 5.36 0 0 0 1.91-2.73l.03-.07-1.94-.52-.03.07c-.1.32-.26.64-.45.94-.17.27-.4.52-.7.74-.29.21-.64.39-1.05.51-.4.12-.9.19-1.45.19a3.8 3.8 0 0 1-2.85-1.05 4.07 4.07 0 0 1-1.09-2.7h9.68v-1.61c0-.53-.12-1.12-.34-1.74A5.03 5.03 0 0 0 88.1 15Zm-8.24 3.21a3.83 3.83 0 0 1 1.22-2.25 4.2 4.2 0 0 1 3.99-.7c.44.16.83.38 1.16.66.35.27.62.62.83 1.02.2.38.33.8.37 1.27h-7.57ZM73.65 19.42a6.11 6.11 0 0 0-3.23-1.02 6.63 6.63 0 0 1-2.68-.58c-.47-.3-.7-.7-.7-1.25 0-.27.08-.5.21-.7.14-.2.33-.38.56-.52a4.05 4.05 0 0 1 1.78-.42c.85 0 1.54.21 2.06.63.53.41.83 1 .91 1.73l.01.1 1.95-.47-.01-.07c-.07-.45-.22-.91-.45-1.36a3.46 3.46 0 0 0-.94-1.2 4.6 4.6 0 0 0-1.52-.84 6.05 6.05 0 0 0-2.1-.34c-.58 0-1.13.07-1.65.22-.52.14-1 .36-1.43.65-.41.3-.74.66-.99 1.1a2.9 2.9 0 0 0-.37 1.49v.14c0 1.06.38 1.87 1.14 2.42a6.2 6.2 0 0 0 3.24.97c1.18.08 2.04.26 2.56.54.5.28.75.72.75 1.36 0 .6-.25 1.06-.78 1.4-.52.32-1.22.48-2.1.48a3.68 3.68 0 0 1-2.46-.79 3.13 3.13 0 0 1-1.04-2.14v-.09l-1.93.46h-.02v.07a4.4 4.4 0 0 0 3.1 4c.7.24 1.52.36 2.46.36.7 0 1.35-.09 1.93-.27.6-.16 1.12-.4 1.53-.72A3.38 3.38 0 0 0 74.8 22v-.14c0-1.07-.39-1.9-1.14-2.44ZM60.25 23.4c-.1-.1-.14-.27-.14-.49v-9.46h-2.05v1.85h-.16a3.78 3.78 0 0 0-1.61-1.63 4.62 4.62 0 0 0-2.26-.56c-.77 0-1.5.14-2.19.41a5.27 5.27 0 0 0-3.02 3.12c-.29.75-.44 1.63-.44 2.6v.38c0 .99.15 1.87.44 2.63.3.75.7 1.4 1.2 1.93a5.48 5.48 0 0 0 4.05 1.57c.8 0 1.51-.2 2.2-.58.69-.38 1.24-.97 1.63-1.75h.16v.06c0 .56.18 1.03.53 1.4.37.35.85.54 1.41.54h1.36v-1.87h-.68c-.2 0-.34-.05-.43-.15Zm-4.46.13c-.46.2-.97.3-1.52.3a3.68 3.68 0 0 1-2.75-1.09 4.42 4.42 0 0 1-1.05-3.12v-.38c0-.62.1-1.2.29-1.71a3.65 3.65 0 0 1 5-2.17c.47.2.88.49 1.21.86.35.37.62.83.8 1.36.2.5.3 1.08.3 1.7v.3c0 .63-.1 1.23-.3 1.76-.18.5-.45.96-.78 1.33-.33.37-.73.66-1.2.86Z"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" height="34" viewBox="0 0 131 34">
|
||||
<path fill="currentColor" d="M.36 8.6h16.7v5.6H6.04c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h11.02v5.6h-5.33c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h4.98c.2 0 .35-.15.35-.35V25.4h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34v-5.6h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34V3.35c0-.2-.16-.35-.35-.35H.36c-.2 0-.36.16-.36.35v4.9c0 .2.16.35.36.35ZM44.41 14.7c-.5-.5-1.1-.9-1.76-1.18a5.62 5.62 0 0 0-4.6.17c-.73.37-1.32.91-1.75 1.62h-.17V8.59H34.1v16.83h2.04v-1.81h.17c.21.36.47.67.77.94.31.25.65.48 1.01.67.37.18.77.31 1.18.39a6.2 6.2 0 0 0 3.39-.24 5.36 5.36 0 0 0 3.02-3.1c.29-.75.44-1.62.44-2.6v-.47c0-.96-.16-1.83-.47-2.58-.3-.75-.7-1.4-1.23-1.9v-.01Zm-5.87.66a3.9 3.9 0 0 1 4.34.84c.36.35.64.8.83 1.3.2.5.3 1.07.3 1.7v.47c0 .64-.1 1.23-.3 1.74a3.75 3.75 0 0 1-2.06 2.15 4.27 4.27 0 0 1-3.12-.03 3.86 3.86 0 0 1-2.09-2.2c-.2-.52-.3-1.11-.3-1.75v-.29c0-.62.1-1.2.3-1.7v-.01c.21-.53.5-.99.84-1.36.36-.37.78-.66 1.26-.86ZM97.04 8.59H95v4.86h-2.94v1.86H95v8.17c0 .56.17 1.03.53 1.4.37.35.84.54 1.4.54h4.18v-1.87h-3.5c-.2 0-.33-.05-.43-.15-.1-.1-.14-.27-.14-.49v-7.6h4.65v-1.86h-4.65V8.59ZM114.61 15a5.48 5.48 0 0 0-1.8-1.33 5.6 5.6 0 0 0-2.57-.56 6.17 6.17 0 0 0-4.26 1.7 5.6 5.6 0 0 0-1.72 4.2v.57c0 .9.15 1.75.44 2.5a5.58 5.58 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.35 5.35 0 0 0 1.91-2.73l.03-.07-1.94-.52-.02.07c-.11.33-.27.64-.46.94-.17.27-.4.52-.7.74-.28.22-.63.39-1.04.51-.41.13-.9.19-1.46.19a3.8 3.8 0 0 1-2.84-1.05 4.07 4.07 0 0 1-1.1-2.7h9.68v-1.6c0-.54-.11-1.12-.34-1.75a5.04 5.04 0 0 0-1.03-1.74Zm-8.25 3.21a3.8 3.8 0 0 1 1.22-2.25 4.19 4.19 0 0 1 3.99-.7c.44.16.83.38 1.17.66.34.27.62.62.82 1.02.21.38.34.8.38 1.27h-7.58ZM129.09 14.42a4.47 4.47 0 0 0-3.37-1.3c-.93 0-1.73.2-2.4.59-.64.39-1.15.97-1.52 1.74h-.17v-2h-2.04v11.97h2.04v-6.23c0-1.26.32-2.28.95-3.02a3.31 3.31 0 0 1 2.65-1.14c.94 0 1.7.3 2.24.9.56.6.83 1.52.83 2.74v6.75h2.04v-7.13c0-1.71-.42-3.02-1.25-3.87ZM88.1 15a5.48 5.48 0 0 0-1.78-1.33 5.6 5.6 0 0 0-2.58-.56 6.17 6.17 0 0 0-4.27 1.7 5.59 5.59 0 0 0-1.71 4.2v.56c0 .92.14 1.76.44 2.51a5.6 5.6 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.36 5.36 0 0 0 1.91-2.73l.03-.07-1.94-.52-.03.07c-.1.32-.26.64-.45.94-.17.27-.4.52-.7.74-.29.21-.64.39-1.05.51-.4.12-.9.19-1.45.19a3.8 3.8 0 0 1-2.85-1.05 4.07 4.07 0 0 1-1.09-2.7h9.68v-1.61c0-.53-.12-1.12-.34-1.74A5.03 5.03 0 0 0 88.1 15Zm-8.24 3.21a3.83 3.83 0 0 1 1.22-2.25 4.2 4.2 0 0 1 3.99-.7c.44.16.83.38 1.16.66.35.27.62.62.83 1.02.2.38.33.8.37 1.27h-7.57ZM73.65 19.42a6.11 6.11 0 0 0-3.23-1.02 6.63 6.63 0 0 1-2.68-.58c-.47-.3-.7-.7-.7-1.25 0-.27.08-.5.21-.7.14-.2.33-.38.56-.52a4.05 4.05 0 0 1 1.78-.42c.85 0 1.54.21 2.06.63.53.41.83 1 .91 1.73l.01.1 1.95-.47-.01-.07c-.07-.45-.22-.91-.45-1.36a3.46 3.46 0 0 0-.94-1.2 4.6 4.6 0 0 0-1.52-.84 6.05 6.05 0 0 0-2.1-.34c-.58 0-1.13.07-1.65.22-.52.14-1 .36-1.43.65-.41.3-.74.66-.99 1.1a2.9 2.9 0 0 0-.37 1.49v.14c0 1.06.38 1.87 1.14 2.42a6.2 6.2 0 0 0 3.24.97c1.18.08 2.04.26 2.56.54.5.28.75.72.75 1.36 0 .6-.25 1.06-.78 1.4-.52.32-1.22.48-2.1.48a3.68 3.68 0 0 1-2.46-.79 3.13 3.13 0 0 1-1.04-2.14v-.09l-1.93.46h-.02v.07a4.4 4.4 0 0 0 3.1 4c.7.24 1.52.36 2.46.36.7 0 1.35-.09 1.93-.27.6-.16 1.12-.4 1.53-.72A3.38 3.38 0 0 0 74.8 22v-.14c0-1.07-.39-1.9-1.14-2.44ZM60.25 23.4c-.1-.1-.14-.27-.14-.49v-9.46h-2.05v1.85h-.16a3.78 3.78 0 0 0-1.61-1.63 4.62 4.62 0 0 0-2.26-.56c-.77 0-1.5.14-2.19.41a5.27 5.27 0 0 0-3.02 3.12c-.29.75-.44 1.63-.44 2.6v.38c0 .99.15 1.87.44 2.63.3.75.7 1.4 1.2 1.93a5.48 5.48 0 0 0 4.05 1.57c.8 0 1.51-.2 2.2-.58.69-.38 1.24-.97 1.63-1.75h.16v.06c0 .56.18 1.03.53 1.4.37.35.85.54 1.41.54h1.36v-1.87h-.68c-.2 0-.34-.05-.43-.15Zm-4.46.13c-.46.2-.97.3-1.52.3a3.68 3.68 0 0 1-2.75-1.09 4.42 4.42 0 0 1-1.05-3.12v-.38c0-.62.1-1.2.29-1.71a3.65 3.65 0 0 1 5-2.17c.47.2.88.49 1.21.86.35.37.62.83.8 1.36.2.5.3 1.08.3 1.7v.3c0 .63-.1 1.23-.3 1.76-.18.5-.45.96-.78 1.33-.33.37-.73.66-1.2.86Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="#355146" fill-rule="evenodd" d="M6.82 11.908c.525 0 1.57-.03 3.013-.639 1.682-.71 5.029-2 7.443-3.323 1.689-.926 2.429-2.151 2.429-3.8 0-2.29-1.81-4.146-4.043-4.146H6.307C3.1 0 .5 2.666.5 5.954s2.434 5.954 6.32 5.954" clip-rule="evenodd"/><path fill="#d18ee2" fill-rule="evenodd" d="M8.402 16.01c0-1.611.947-3.064 2.399-3.682l2.946-1.254c2.98-1.268 6.26.977 6.26 4.286 0 2.563-2.027 4.64-4.527 4.64l-3.19-.002c-2.147 0-3.888-1.785-3.888-3.987" clip-rule="evenodd"/><path fill="#ff7759" d="M3.848 12.691C1.998 12.691.5 14.228.5 16.124v.444C.5 18.464 1.999 20 3.848 20s3.347-1.536 3.347-3.432v-.444c0-1.896-1.499-3.433-3.347-3.433"/></svg>
|
||||
|
Before Width: | Height: | Size: 709 B |
@@ -1,9 +1,9 @@
|
||||
<svg width="109" height="24" viewBox="0 0 109 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_125_22125)">
|
||||
<path d="M0 -2.08616e-07V24H17.9352C22.9911 24 26.0999 21.0858 26.0999 17.04V6.96C26.0999 2.91432 22.9911 -2.08616e-07 17.9352 -2.08616e-07H0ZM6.76413 5.82864H19.1992V18.1714H6.76413V5.82864Z" fill="#9CA3AF"/>
|
||||
<path d="M46.7659 18.6172H35.0824V14.16H46.7659V18.6172ZM46.595 5.38296V9.5658H35.0824V5.38296H46.595ZM50.2846 12.1373V11.5886C52.5734 10.5258 53.7008 8.8458 53.7008 6.13728C53.7008 2.64012 50.9337 0.000116183 45.5361 0.000116183H28.3184V24H45.7752C51.1728 24 53.9399 21.8401 53.9399 18.0685C53.9399 15.0172 52.6418 13.2001 50.2846 12.1373Z" fill="#9CA3AF"/>
|
||||
<path d="M62.397 18.1714H74.8319V5.82864H62.397V18.1714ZM63.6609 24C58.6049 24 55.4961 21.0858 55.4961 17.04V6.96012C55.4961 2.91432 58.6049 0.000116183 63.6609 0.000116183H73.568C78.6238 0.000116183 81.7326 2.91432 81.7326 6.96012V17.04C81.7326 21.0858 78.6238 24 73.568 24H63.6609Z" fill="#9CA3AF"/>
|
||||
<path d="M101.66 15.12L90.8995 14.3658C85.5361 13.9886 83.418 11.1772 83.418 7.47432V6.96012C83.418 2.91432 86.5266 0.000116183 91.5827 0.000116183H100.157C105.214 0.000116183 108.323 2.91432 108.323 6.96012V7.98864H101.968V5.14284H90.2504V8.43432L100.601 9.18864C105.999 9.56568 108.493 12.8572 108.493 16.5257V17.04C108.493 20.7428 105.384 24 100.328 24H91.5827C86.5266 24 83.418 20.7428 83.418 17.04V16.0115H89.7722V18.8572H101.66V15.12Z" fill="#9CA3AF"/>
|
||||
<path d="M0 -2.08616e-07V24H17.9352C22.9911 24 26.0999 21.0858 26.0999 17.04V6.96C26.0999 2.91432 22.9911 -2.08616e-07 17.9352 -2.08616e-07H0ZM6.76413 5.82864H19.1992V18.1714H6.76413V5.82864Z" fill="currentColor"/>
|
||||
<path d="M46.7659 18.6172H35.0824V14.16H46.7659V18.6172ZM46.595 5.38296V9.5658H35.0824V5.38296H46.595ZM50.2846 12.1373V11.5886C52.5734 10.5258 53.7008 8.8458 53.7008 6.13728C53.7008 2.64012 50.9337 0.000116183 45.5361 0.000116183H28.3184V24H45.7752C51.1728 24 53.9399 21.8401 53.9399 18.0685C53.9399 15.0172 52.6418 13.2001 50.2846 12.1373Z" fill="currentColor"/>
|
||||
<path d="M62.397 18.1714H74.8319V5.82864H62.397V18.1714ZM63.6609 24C58.6049 24 55.4961 21.0858 55.4961 17.04V6.96012C55.4961 2.91432 58.6049 0.000116183 63.6609 0.000116183H73.568C78.6238 0.000116183 81.7326 2.91432 81.7326 6.96012V17.04C81.7326 21.0858 78.6238 24 73.568 24H63.6609Z" fill="currentColor"/>
|
||||
<path d="M101.66 15.12L90.8995 14.3658C85.5361 13.9886 83.418 11.1772 83.418 7.47432V6.96012C83.418 2.91432 86.5266 0.000116183 91.5827 0.000116183H100.157C105.214 0.000116183 108.323 2.91432 108.323 6.96012V7.98864H101.968V5.14284H90.2504V8.43432L100.601 9.18864C105.999 9.56568 108.493 12.8572 108.493 16.5257V17.04C108.493 20.7428 105.384 24 100.328 24H91.5827C86.5266 24 83.418 20.7428 83.418 17.04V16.0115H89.7722V18.8572H101.66V15.12Z" fill="currentColor"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_125_22125">
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="#EF4136" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2.2" y="2.2" width="19.6" height="19.6" rx="0.6"/>
|
||||
<path d="M12 2.2V4.7a1.75 1.75 0 1 0 0 3.5V12"/>
|
||||
<path d="M12 12v2.5a1.75 1.75 0 1 1 0 3.5v3.8"/>
|
||||
<path d="M2.2 12h2.5a1.75 1.75 0 1 0 3.5 0H12"/>
|
||||
<path d="M12 12h2.8a1.75 1.75 0 1 1 3.5 0h3.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 463 B |
@@ -1,3 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#00BFB3" d="M13.394 0C8.683 0 4.609 2.716 2.644 6.667h15.641a4.77 4.77 0 0 0 3.073-1.11c.446-.375.864-.785 1.247-1.243l.001-.002A11.974 11.974 0 0 0 13.394 0zM1.804 8.889a12.009 12.009 0 0 0 0 6.222h14.7a3.111 3.111 0 1 0 0-6.222zm.84 8.444C4.61 21.283 8.684 24 13.395 24c3.701 0 7.011-1.677 9.212-4.312l-.001-.002a9.958 9.958 0 0 0-1.247-1.243 4.77 4.77 0 0 0-3.073-1.11z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 469 B |
@@ -1 +1 @@
|
||||
<svg fill="#F55036" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Groq</title><path d="M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z"></path></svg>
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Groq</title><path d="M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 563 B After Width: | Height: | Size: 568 B |
@@ -1,74 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 102.04 102.04" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="45.34" y="23.92" width=".41" height="15.95" transform="translate(-9.7 38.24) rotate(-41.55)" fill="#106DA9"/>
|
||||
<rect x="32.04" y="45.06" width="11.92" height="11.92" transform="translate(-13.28 88.67) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="45.04" y="32.06" width="11.92" height="11.92" transform="translate(12.63 88.76) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="45.04" y="58.06" width="11.92" height="11.92" transform="translate(-13.37 114.58) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="58.04" y="45.06" width="11.92" height="11.92" transform="translate(12.54 114.67) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="57.03" y="22.05" width="7.94" height="7.94" transform="translate(34.56 86.84) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="36.03" y="22.05" width="7.94" height="7.94" transform="translate(13.7 65.84) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="22.03" y="37.05" width="7.94" height="7.94" transform="translate(-15.2 66.73) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="22.03" y="58.05" width="7.94" height="7.94" transform="translate(-36.2 87.59) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="72.03" y="58.05" width="7.94" height="7.94" transform="translate(13.45 137.58) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="72.03" y="37.05" width="7.94" height="7.94" transform="translate(34.45 116.73) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="15.02" y="26.04" width="5.96" height="5.96" transform="translate(-11.15 46.82) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="25.02" y="14.04" width="5.96" height="5.96" transform="translate(10.79 44.9) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="40.02" y="9.04" width="5.96" height="5.96" transform="translate(30.68 54.94) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="11.02" y="41.04" width="5.96" height="5.96" transform="translate(-30.12 57.71) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="81.98" y="26" width="5.04" height="5.04" transform="translate(-.2 .59) rotate(-.4)" fill="#106DA9"/>
|
||||
<rect x="70.98" y="15" width="5.04" height="5.04" transform="translate(-.12 .51) rotate(-.4)" fill="#106DA9"/>
|
||||
<rect x="55.98" y="9" width="5.04" height="5.04" transform="translate(-.08 .41) rotate(-.4)" fill="#106DA9"/>
|
||||
<rect x="84.98" y="41" width="5.04" height="5.03" transform="translate(-.3 .6) rotate(-.4)" fill="#106DA9"/>
|
||||
<rect x="36.03" y="72.05" width="7.94" height="7.94" transform="translate(-36.3 115.49) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="57.03" y="72.05" width="7.94" height="7.94" transform="translate(-15.44 136.49) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="81.02" y="70.04" width="5.96" height="5.96" transform="translate(10.39 156.51) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="70.02" y="82.04" width="5.96" height="5.96" transform="translate(-12.52 157.43) rotate(-89.6)" fill="#106DA9"/>
|
||||
<rect x="56.02" y="87.04" width="5.96" height="5.96" transform="translate(-31.44 148.37) rotate(-89.59)" fill="#106DA9"/>
|
||||
<rect x="84.02" y="55.04" width="5.96" height="5.96" transform="translate(28.35 144.6) rotate(-89.59)" fill="#106DA9"/>
|
||||
<rect x="14.98" y="71" width="5.04" height="5.04" transform="translate(-.51 .12) rotate(-.4)" fill="#106DA9"/>
|
||||
<rect x="25.98" y="82" width="5.03" height="5.04" transform="translate(-.61 .21) rotate(-.41)" fill="#106DA9"/>
|
||||
<rect x="40.98" y="87" width="5.04" height="5.04" transform="translate(-.62 .3) rotate(-.4)" fill="#106DA9"/>
|
||||
<rect x="11.98" y="55" width="5.04" height="5.04" transform="translate(-.4 .1) rotate(-.4)" fill="#106DA9"/>
|
||||
<rect x="18.01" y="12.03" width="2.98" height="2.98" transform="translate(5.85 32.93) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x=".01" y="45.03" width="2.98" height="2.98" transform="translate(-45.03 47.71) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="2.01" y="35.03" width="2.98" height="2.98" transform="translate(-33.04 39.77) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="35.01" y="2.03" width="2.98" height="2.98" transform="translate(32.73 40) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="6.01" y="26.03" width="2.98" height="2.98" transform="translate(-20.07 34.83) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="11.01" y="18.03" width="2.98" height="2.98" transform="translate(-7.11 31.89) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="25.99" y="6.01" width="3.02" height="3.02" transform="translate(-.05 .19) rotate(-.39)" fill="#106DA9"/>
|
||||
<rect x="44.01" y=".03" width="2.98" height="2.98" transform="translate(0 .31) rotate(-.39)" fill="#106DA9"/>
|
||||
<rect x="93.01" y="26.03" width="2.98" height="2.98" transform="translate(66.34 121.83) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="97.01" y="35.03" width="2.98" height="2.98" transform="translate(61.31 134.77) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="88.01" y="18.03" width="2.98" height="2.98" transform="translate(69.37 108.89) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="98.01" y="45.03" width="2.98" height="2.98" transform="translate(52.3 145.7) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="53.99" y=".01" width="3.02" height="3.02" transform="translate(53.6 57.01) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="64.01" y="2.03" width="2.98" height="2.98" transform="translate(61.53 69) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="18.01" y="12.03" width="2.98" height="2.98" transform="translate(5.85 32.93) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x=".01" y="45.03" width="2.98" height="2.98" transform="translate(-45.03 47.71) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="81.01" y="12.03" width="2.98" height="2.98" transform="translate(68.42 95.93) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="2.01" y="35.03" width="2.98" height="2.98" transform="translate(-33.04 39.77) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="35.01" y="2.03" width="2.98" height="2.98" transform="translate(32.73 40) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="6.01" y="26.03" width="2.98" height="2.98" transform="translate(-20.07 34.83) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="11.01" y="18.03" width="2.98" height="2.98" transform="translate(-7.11 31.89) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="25.99" y="6.01" width="3.02" height="3.02" transform="translate(-.05 .19) rotate(-.39)" fill="#106DA9"/>
|
||||
<rect x="44.01" y=".03" width="2.98" height="2.98" transform="translate(0 .31) rotate(-.39)" fill="#106DA9"/>
|
||||
<rect x="73.01" y="6.03" width="2.98" height="2.98" transform="translate(66.47 81.97) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="98.01" y="45.03" width="2.98" height="2.98" transform="translate(52.3 145.7) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="53.99" y=".01" width="3.02" height="3.02" transform="translate(53.6 57.01) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="64.01" y="2.03" width="2.98" height="2.98" transform="translate(61.53 69) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="81.01" y="88.03" width="2.98" height="2.98" transform="translate(-7.58 171.41) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="98.01" y="54.03" width="2.98" height="2.98" transform="translate(43.31 154.64) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="18.01" y="88.03" width="2.98" height="2.98" transform="translate(-70.15 108.42) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="97.01" y="64.03" width="2.98" height="2.98" transform="translate(32.32 163.58) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="64.01" y="97.04" width="2.97" height="2.97" transform="translate(-33.67 163.03) rotate(-89.43)" fill="#106DA9"/>
|
||||
<rect x="93.01" y="73.03" width="2.98" height="2.98" transform="translate(19.31 168.49) rotate(-89.59)" fill="#106DA9"/>
|
||||
<rect x="88.01" y="81.03" width="2.98" height="2.98" transform="translate(6.37 171.45) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="72.99" y="93.01" width="3.02" height="3.02" transform="translate(-.65 .51) rotate(-.39)" fill="#106DA9"/>
|
||||
<rect x="53.99" y="99.01" width="3.02" height="3.02" transform="translate(-.68 .38) rotate(-.39)" fill="#106DA9"/>
|
||||
<rect x="26.01" y="93.03" width="2.98" height="2.98" transform="translate(-67.21 121.37) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="6.01" y="73.03" width="2.98" height="2.98" transform="translate(-67.07 81.51) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="2.01" y="64.03" width="2.98" height="2.98" transform="translate(-62.04 68.57) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="11.01" y="81.03" width="2.98" height="2.98" transform="translate(-70.1 94.46) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x=".01" y="54.03" width="2.98" height="2.98" transform="translate(-54.03 56.65) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="45.01" y="99.03" width="2.98" height="2.98" transform="translate(-54.33 146.34) rotate(-89.61)" fill="#106DA9"/>
|
||||
<rect x="35.01" y="97.03" width="2.98" height="2.98" transform="translate(-62.27 134.34) rotate(-89.61)" fill="#106DA9"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFEC6E" d="m0 0 11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 398 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#9CA3AF" d="M9.755 1.52h-.001c-.31 0-.608.124-.828.343L4.037 6.752a1.17 1.17 0 0 1-.827.343H1.17A1.17 1.17 0 0 0 0 8.295l.052 1.984a1.17 1.17 0 0 0 1.17 1.14h2.37c.31 0 .607-.124.827-.344l4.93-4.93c.22-.22.517-.343.827-.343h2.874a1.17 1.17 0 0 0 1.17-1.17V2.69a1.17 1.17 0 0 0-1.17-1.17zm9.78 2.503c-.31 0-.608.123-.828.343l-4.889 4.889a1.17 1.17 0 0 1-.827.342h-2.756c-.31 0-.608.124-.827.344L4.15 15.197a1.17 1.17 0 0 1-.827.343H1.32a1.17 1.17 0 0 0-1.17 1.17v1.996c0 .646.524 1.17 1.17 1.17h2.017c.302 0 .592-.116.81-.325l5.535-5.304a1.17 1.17 0 0 1 .81-.326h2.88c.31 0 .607-.123.827-.342l4.93-4.93c.22-.22.517-.344.827-.344h2.873A1.17 1.17 0 0 0 24 7.135V5.193a1.17 1.17 0 0 0-1.17-1.17h-3.294zm0 8.559c-.31 0-.608.123-.828.343l-4.889 4.889a1.17 1.17 0 0 1-.827.343h-2.04a1.17 1.17 0 0 0-1.17 1.2l.052 1.984a1.17 1.17 0 0 0 1.17 1.14h2.37c.31 0 .607-.124.827-.343l4.93-4.93c.22-.22.517-.343.827-.343h2.873a1.17 1.17 0 0 0 1.17-1.17v-1.943a1.17 1.17 0 0 0-1.17-1.17h-3.294Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.34" y="2.34" width="19.27" height="19.27" rx="4.69" fill="#fff"/>
|
||||
<circle cx="10.41" cy="11.95" r="2.77" fill="none" stroke="#000" stroke-width="1.36"/>
|
||||
<rect x="15" y="8.53" width="1.36" height="6.94" fill="#000"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 312 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#9CA3AF" viewBox="0 0 24 24"><title>PlanetScale</title><path d="M0 12C0 5.373 5.373 0 12 0c4.873 0 9.067 2.904 10.947 7.077l-15.87 15.87a12 12 0 0 1-1.935-1.099L14.99 12H12l-8.485 8.485A11.96 11.96 0 0 1 0 12m12.004 12L24 12.004C23.998 18.628 18.628 23.998 12.004 24"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><title>PlanetScale</title><path d="M0 12C0 5.373 5.373 0 12 0c4.873 0 9.067 2.904 10.947 7.077l-15.87 15.87a12 12 0 0 1-1.935-1.099L14.99 12H12l-8.485 8.485A11.96 11.96 0 0 1 0 12m12.004 12L24 12.004C23.998 18.628 18.628 23.998 12.004 24"/></svg>
|
||||
|
Before Width: | Height: | Size: 321 B After Width: | Height: | Size: 326 B |
@@ -1,3 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#FF6600" d="M23.035 9.601h-7.677a.956.956 0 01-.962-.962V.962a.956.956 0 00-.962-.956H10.56a.956.956 0 00-.962.956V8.64a.956.956 0 01-.962.962H5.762a.956.956 0 01-.961-.962V.962A.956.956 0 003.839 0H.959a.956.956 0 00-.956.962v22.076A.956.956 0 00.965 24h22.07a.956.956 0 00.962-.962V10.58a.956.956 0 00-.962-.98zm-3.86 8.152a1.437 1.437 0 01-1.437 1.443h-1.924a1.437 1.437 0 01-1.436-1.443v-1.917a1.437 1.437 0 011.436-1.443h1.924a1.437 1.437 0 011.437 1.443z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 557 B |
@@ -1,3 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#9CA3AF" d="M.113 10.27A13.026 13.026 0 000 11.48h18.23c-.064-.125-.15-.237-.235-.347-3.117-4.027-4.793-3.677-7.19-3.78-.8-.034-1.34-.048-4.524-.048-1.704 0-3.555.005-5.358.01-.234.63-.459 1.24-.567 1.737h9.342v1.216H.113v.002zm18.26 2.426H.009c.02.326.05.645.094.961h16.955c.754 0 1.179-.429 1.315-.96zm-17.318 4.28s2.81 6.902 10.93 7.024c4.855 0 9.027-2.883 10.92-7.024H1.056zM11.988 0C7.5 0 3.593 2.466 1.531 6.108l4.75-.005v-.002c3.71 0 3.849.016 4.573.047l.448.016c1.563.052 3.485.22 4.996 1.364.82.621 2.007 1.99 2.712 2.965.654.902.842 1.94.396 2.934-.408.914-1.289 1.458-2.353 1.458H.391s.099.42.249.886h22.748A12.026 12.026 0 0024 12.005C24 5.377 18.621 0 11.988 0z"/>
|
||||
<path fill="currentColor" d="M.113 10.27A13.026 13.026 0 000 11.48h18.23c-.064-.125-.15-.237-.235-.347-3.117-4.027-4.793-3.677-7.19-3.78-.8-.034-1.34-.048-4.524-.048-1.704 0-3.555.005-5.358.01-.234.63-.459 1.24-.567 1.737h9.342v1.216H.113v.002zm18.26 2.426H.009c.02.326.05.645.094.961h16.955c.754 0 1.179-.429 1.315-.96zm-17.318 4.28s2.81 6.902 10.93 7.024c4.855 0 9.027-2.883 10.92-7.024H1.056zM11.988 0C7.5 0 3.593 2.466 1.531 6.108l4.75-.005v-.002c3.71 0 3.849.016 4.573.047l.448.016c1.563.052 3.485.22 4.996 1.364.82.621 2.007 1.99 2.712 2.965.654.902.842 1.94.396 2.934-.408.914-1.289 1.458-2.353 1.458H.391s.099.42.249.886h22.748A12.026 12.026 0 0024 12.005C24 5.377 18.621 0 11.988 0z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 771 B After Width: | Height: | Size: 776 B |
@@ -1,13 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 590 270">
|
||||
<path d="M30.36,109.14v.48h0A3.73,3.73,0,0,1,30.36,109.14Z" fill="#de3423" fill-rule="evenodd"/>
|
||||
<path d="M30.36,109.14v.48h0A3.73,3.73,0,0,1,30.36,109.14Z" fill="currentColor" fill-rule="evenodd"/>
|
||||
<path d="M138.66,28.78C107.2,37.87,57.29,43,30.4,43h0V94.35a.8.8,0,0,0,.19.48c18.35,0,75-6,109.18-15.4a129,129,0,0,0,17.49-5.81c4.18-1.88,6.88-3.86,6.88-5.92V15.91C164.1,20.79,151.39,25.11,138.66,28.78Z" fill="#de3423" fill-rule="evenodd"/>
|
||||
<path d="M138.66,95.37c-18.83,5.43-44.24,9.47-67.39,11.83-15.54,1.59-30.06,2.42-40.87,2.42h0v51.31a.8.8,0,0,0,.19.48c18.35,0,75-6,109.18-15.39a130.38,130.38,0,0,0,17.49-5.81c4.18-1.89,6.88-3.86,6.88-5.92V82.5C164.1,87.37,151.39,91.69,138.66,95.37Z" fill="#de3423" fill-rule="evenodd"/>
|
||||
<path d="M138.66,162c-18.83,5.43-44.24,9.46-67.39,11.83-15.56,1.59-30.1,2.42-40.91,2.42V228c18.16,0,75.1-5.95,109.37-15.39,12.63-3.48,24.37-7.44,24.37-11.74V149.08C164.1,154,151.39,158.28,138.66,162Z" fill="#de3423" fill-rule="evenodd"/>
|
||||
<path d="M30.55,94.83C32.4,97.38,48,102.19,71.27,107.2c23.27,4.46,47.47,22.07,66.29,16.64,12.73-3.68,26.54-36.47,26.54-41.34V82c0-3.4-2.55-6.13-6.88-8.4-17.75-9.07-21.11-12.41-27.69-10.6C95.37,72.43,35.06,67.61,30.55,94.83Z" fill="#de3423" fill-rule="evenodd"/>
|
||||
<path d="M30.55,161.41C32.4,164,48,168.77,71.27,173.79c26,4.74,48.61,20.19,67.44,14.75,12.73-3.68,25.39-34.58,25.39-39.46v-.48c0-3.39-2.55-6.13-6.88-8.39-13.54-7.2-31.43-15.13-38-13.32C85,136.3,39.26,138.37,30.55,161.41Z" fill="#de3423" fill-rule="evenodd"/>
|
||||
<path d="M200.7,142.39c6,11.79,15.6,17.6,29.05,17.6,14.44,0,19.59-7.64,19.59-15.11,0-5.15-1.83-8.63-6.64-11.79-4.82-3.32-8.3-4.81-16.93-8-10.63-4-16.77-7-23.41-12.29-6.64-5.48-9.79-13-9.79-22.74a28.28,28.28,0,0,1,10.29-22.58c7-5.81,15.44-8.63,25.56-8.63,15.77,0,27.72,6.31,35.69,18.76L249.34,87.78c-4.48-6.81-11.29-10.3-20.59-10.3-9.13,0-15.77,5.15-15.77,12.29,0,4.81,2,7.14,4.82,10,1.82,1.33,6.47,3.32,8.63,4.48l6,2.32,6.8,2.66c11,4.48,18.76,9.3,23.57,14.44s7.31,12.12,7.31,20.75c0,20.42-14.11,34.2-40.51,34.2-21.41,0-37.18-10-44.48-26.4Z" fill="#de3423"/>
|
||||
<path d="M354.25,104.71,342,117.49a28.14,28.14,0,0,0-21.24-9.13,25,25,0,0,0-18.43,7.47,27.76,27.76,0,0,0,0,37.52,25,25,0,0,0,18.43,7.47A28.14,28.14,0,0,0,342,151.69l12.29,12.78c-9,9.63-20.09,14.44-33.53,14.44-12.79,0-23.58-4.15-32.37-12.62s-13.12-19.09-13.12-31.7,4.32-23.08,13.12-31.54,19.58-12.78,32.37-12.78C334.16,90.27,345.28,95.08,354.25,104.71Z" fill="#de3423"/>
|
||||
<path d="M393.88,125.62C408,124.3,413,122.47,413,116c0-5.15-4.64-9.13-13.94-9.13q-13.44,0-22.41,10.95l-12.28-10.46c8.13-11.45,19.58-17.09,34.36-17.09,20.75,0,33.7,10,33.7,27.05v37c0,5.81,2.15,6.48,7,6.48h.5v15.43c-2,1.17-5.15,1.83-9.3,1.83-4.48,0-8-1.33-10.62-4a14.06,14.06,0,0,1-3-5.48c-5.81,6.8-15.27,10.29-28.39,10.29-18.42,0-30.87-10.13-30.87-25.4C357.7,136.41,369.15,127.78,393.88,125.62ZM391.56,162c13.28,0,21.41-6,21.41-16.6v-9.3a9.75,9.75,0,0,1-4.14,2.49c-3.82,1.33-6.31,1.66-14.28,2.49-11.62,1.33-17.43,5-17.43,10.79C377.12,158.33,382.43,162,391.56,162Z" fill="#de3423"/>
|
||||
<path d="M444.84,60.88h19.92V149.2c0,8.13,2.66,11.62,10,11.62a21.15,21.15,0,0,0,6-.67v17.76a35.56,35.56,0,0,1-9.47,1c-17.59,0-26.39-9-26.39-27.06Z" fill="#de3423"/>
|
||||
<path d="M521.71,125.62c14.11-1.32,19.09-3.15,19.09-9.62,0-5.15-4.64-9.13-13.94-9.13q-13.44,0-22.41,10.95l-12.28-10.46c8.13-11.45,19.58-17.09,34.36-17.09,20.75,0,33.7,10,33.7,27.05v37c0,5.81,2.15,6.48,7,6.48h.5v15.43c-2,1.17-5.15,1.83-9.3,1.83-4.48,0-8-1.33-10.62-4a13.94,13.94,0,0,1-3-5.48c-5.81,6.8-15.27,10.29-28.39,10.29-18.42,0-30.87-10.13-30.87-25.4C485.53,136.41,497,127.78,521.71,125.62ZM519.39,162c13.28,0,21.41-6,21.41-16.6v-9.3a9.73,9.73,0,0,1-4.15,2.49c-3.81,1.33-6.3,1.66-14.27,2.49-11.62,1.33-17.43,5-17.43,10.79C505,158.33,510.26,162,519.39,162Z" fill="#de3423"/>
|
||||
<path d="M30.55,94.83C32.4,97.38,48,102.19,71.27,107.2c23.27,4.46,47.47,22.07,66.29,16.64,12.73-3.68,26.54-36.47,26.54-41.34V82c0-3.4-2.55-6.13-6.88-8.4-17.75-9.07-21.11-12.41-27.69-10.6C95.37,72.43,35.06,67.61,30.55,94.83Z" fill="currentColor" fill-rule="evenodd"/>
|
||||
<path d="M30.55,161.41C32.4,164,48,168.77,71.27,173.79c26,4.74,48.61,20.19,67.44,14.75,12.73-3.68,25.39-34.58,25.39-39.46v-.48c0-3.39-2.55-6.13-6.88-8.39-13.54-7.2-31.43-15.13-38-13.32C85,136.3,39.26,138.37,30.55,161.41Z" fill="currentColor" fill-rule="evenodd"/>
|
||||
<path d="M200.7,142.39c6,11.79,15.6,17.6,29.05,17.6,14.44,0,19.59-7.64,19.59-15.11,0-5.15-1.83-8.63-6.64-11.79-4.82-3.32-8.3-4.81-16.93-8-10.63-4-16.77-7-23.41-12.29-6.64-5.48-9.79-13-9.79-22.74a28.28,28.28,0,0,1,10.29-22.58c7-5.81,15.44-8.63,25.56-8.63,15.77,0,27.72,6.31,35.69,18.76L249.34,87.78c-4.48-6.81-11.29-10.3-20.59-10.3-9.13,0-15.77,5.15-15.77,12.29,0,4.81,2,7.14,4.82,10,1.82,1.33,6.47,3.32,8.63,4.48l6,2.32,6.8,2.66c11,4.48,18.76,9.3,23.57,14.44s7.31,12.12,7.31,20.75c0,20.42-14.11,34.2-40.51,34.2-21.41,0-37.18-10-44.48-26.4Z" fill="currentColor"/>
|
||||
<path d="M354.25,104.71,342,117.49a28.14,28.14,0,0,0-21.24-9.13,25,25,0,0,0-18.43,7.47,27.76,27.76,0,0,0,0,37.52,25,25,0,0,0,18.43,7.47A28.14,28.14,0,0,0,342,151.69l12.29,12.78c-9,9.63-20.09,14.44-33.53,14.44-12.79,0-23.58-4.15-32.37-12.62s-13.12-19.09-13.12-31.7,4.32-23.08,13.12-31.54,19.58-12.78,32.37-12.78C334.16,90.27,345.28,95.08,354.25,104.71Z" fill="currentColor"/>
|
||||
<path d="M393.88,125.62C408,124.3,413,122.47,413,116c0-5.15-4.64-9.13-13.94-9.13q-13.44,0-22.41,10.95l-12.28-10.46c8.13-11.45,19.58-17.09,34.36-17.09,20.75,0,33.7,10,33.7,27.05v37c0,5.81,2.15,6.48,7,6.48h.5v15.43c-2,1.17-5.15,1.83-9.3,1.83-4.48,0-8-1.33-10.62-4a14.06,14.06,0,0,1-3-5.48c-5.81,6.8-15.27,10.29-28.39,10.29-18.42,0-30.87-10.13-30.87-25.4C357.7,136.41,369.15,127.78,393.88,125.62ZM391.56,162c13.28,0,21.41-6,21.41-16.6v-9.3a9.75,9.75,0,0,1-4.14,2.49c-3.82,1.33-6.31,1.66-14.28,2.49-11.62,1.33-17.43,5-17.43,10.79C377.12,158.33,382.43,162,391.56,162Z" fill="currentColor"/>
|
||||
<path d="M444.84,60.88h19.92V149.2c0,8.13,2.66,11.62,10,11.62a21.15,21.15,0,0,0,6-.67v17.76a35.56,35.56,0,0,1-9.47,1c-17.59,0-26.39-9-26.39-27.06Z" fill="currentColor"/>
|
||||
<path d="M521.71,125.62c14.11-1.32,19.09-3.15,19.09-9.62,0-5.15-4.64-9.13-13.94-9.13q-13.44,0-22.41,10.95l-12.28-10.46c8.13-11.45,19.58-17.09,34.36-17.09,20.75,0,33.7,10,33.7,27.05v37c0,5.81,2.15,6.48,7,6.48h.5v15.43c-2,1.17-5.15,1.83-9.3,1.83-4.48,0-8-1.33-10.62-4a13.94,13.94,0,0,1-3-5.48c-5.81,6.8-15.27,10.29-28.39,10.29-18.42,0-30.87-10.13-30.87-25.4C485.53,136.41,497,127.78,521.71,125.62ZM519.39,162c13.28,0,21.41-6,21.41-16.6v-9.3a9.73,9.73,0,0,1-4.15,2.49c-3.81,1.33-6.3,1.66-14.27,2.49-11.62,1.33-17.43,5-17.43,10.79C505,158.33,510.26,162,519.39,162Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -1,32 +0,0 @@
|
||||
// Code generated by scripts/semconv. DO NOT EDIT.
|
||||
|
||||
export type SemconvFamily = {
|
||||
readonly current: string;
|
||||
readonly old: readonly string[];
|
||||
readonly kind: 'attribute' | 'metric';
|
||||
readonly contexts: readonly string[];
|
||||
readonly signals: readonly string[];
|
||||
readonly applyToMetrics: readonly string[];
|
||||
readonly valueMap: Readonly<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
|
||||
{
|
||||
current: 'db.system.name',
|
||||
old: ['db.system'],
|
||||
kind: 'attribute',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'deployment.environment.name',
|
||||
old: ['deployment.environment'],
|
||||
kind: 'attribute',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
valueMap: {},
|
||||
},
|
||||
] as const;
|
||||
@@ -37,7 +37,6 @@ export default function ChartWrapper({
|
||||
groupByPerQuery,
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
tooltipPortalRoot,
|
||||
'data-testid': testId,
|
||||
}: ChartProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
@@ -119,7 +118,6 @@ export default function ChartWrapper({
|
||||
syncMetadata={syncMetadata}
|
||||
render={renderTooltipCallback}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
portalRoot={tooltipPortalRoot}
|
||||
/>
|
||||
)}
|
||||
</UPlotChart>
|
||||
|
||||
@@ -30,7 +30,6 @@ interface BaseChartProps {
|
||||
pinnedTooltipElement?: (clickData: ChartClickData) => React.ReactNode;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
interface UPlotBasedChartProps {
|
||||
|
||||
@@ -140,7 +140,6 @@ function Hosts(): JSX.Element {
|
||||
records: data.records,
|
||||
total: data.total,
|
||||
endTimeBeforeRetention: data.endTimeBeforeRetention,
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -40,9 +40,6 @@ export type {
|
||||
|
||||
// TODO(H4ad): Improve this on component level
|
||||
const DRAWER_TRANSITION = { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] };
|
||||
// Be careful when changing these props, this must be animated with transform but later
|
||||
// replaced with none, otherwise, the tooltip of the chart will be not positioned correctly
|
||||
// due to how the tooltip positioning calculation works
|
||||
const DRAWER_MOTION_PROPS = {
|
||||
onOpenAutoFocus: (e: Event): void => e.preventDefault(),
|
||||
initial: { opacity: 0, transform: 'translateX(100%)' },
|
||||
@@ -50,7 +47,6 @@ const DRAWER_MOTION_PROPS = {
|
||||
opacity: 1,
|
||||
transform: 'translateX(0%)',
|
||||
transition: DRAWER_TRANSITION,
|
||||
transitionEnd: { transform: 'none' },
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border-top: 1px dashed var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
.hintList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
--typography-text-display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
position: relative;
|
||||
|
||||
&[data-active='false']::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -12px;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--l2-foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { MousePointerClick, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Kbd } from '@signozhq/ui/kbd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import styles from './ChartTooltipFooter.module.scss';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface ChartTooltipFooterProps {
|
||||
id: string;
|
||||
pinKey?: string;
|
||||
isPinned: boolean;
|
||||
canSelectTimeRange?: boolean;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
export default function ChartTooltipFooter({
|
||||
id,
|
||||
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
|
||||
isPinned,
|
||||
canSelectTimeRange = true,
|
||||
dismiss,
|
||||
}: ChartTooltipFooterProps): JSX.Element {
|
||||
const handleUnpinClick = (): void => {
|
||||
void logEvent(Events.TOOLTIP_UNPINNED, {
|
||||
id,
|
||||
});
|
||||
dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<output className={styles.footer} data-testid="entity-chart-tooltip-footer">
|
||||
<div>
|
||||
{isPinned ? (
|
||||
<Typography.Text className={styles.hint} size="small">
|
||||
<span>Press</span>
|
||||
<Kbd active>{pinKey.toUpperCase()}</Kbd>
|
||||
<span>or</span>
|
||||
<Kbd active>Esc</Kbd>
|
||||
<span>to unpin</span>
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<div className={styles.hintList}>
|
||||
{canSelectTimeRange && (
|
||||
<Typography.Text
|
||||
className={styles.hint}
|
||||
size="small"
|
||||
data-active="false"
|
||||
>
|
||||
<Kbd>
|
||||
<MousePointerClick size={12} />
|
||||
</Kbd>
|
||||
<span>Click and drag to zoom into a time range</span>
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Typography.Text className={styles.hint} size="small" data-active="false">
|
||||
<span>Press</span>
|
||||
<Kbd>{pinKey.toUpperCase()}</Kbd>
|
||||
<span>to pin the tooltip</span>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isPinned && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleUnpinClick}
|
||||
aria-label="Unpin tooltip"
|
||||
data-testid="entity-chart-tooltip-unpin"
|
||||
>
|
||||
<X size={10} />
|
||||
<span>Unpin</span>
|
||||
</Button>
|
||||
)}
|
||||
</output>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendPosition,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
InfraMonitoringEntity,
|
||||
VIEW_TYPES,
|
||||
@@ -36,7 +33,6 @@ import { isKeyNotFoundError } from '../utils';
|
||||
import styles from './EntityMetrics.module.scss';
|
||||
import { MetricsTable } from './MetricsTable';
|
||||
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
import ChartTooltipFooter from './ChartTooltipFooter';
|
||||
|
||||
interface EntityMetricsProps<T> {
|
||||
entity: T;
|
||||
@@ -82,10 +78,6 @@ function EntityMetrics<T>({
|
||||
category,
|
||||
});
|
||||
|
||||
const [tooltipPortalEl, setTooltipPortalEl] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
@@ -179,18 +171,6 @@ function EntityMetrics<T>({
|
||||
height={dimensions.height}
|
||||
timezone={timezone}
|
||||
yAxisUnit={entityWidgetInfo[idx].yAxisUnit}
|
||||
canPinTooltip
|
||||
tooltipPortalRoot={tooltipPortalEl ?? undefined}
|
||||
renderTooltipFooter={({
|
||||
isPinned,
|
||||
dismiss,
|
||||
}: IRenderTooltipFooterArgs) => (
|
||||
<ChartTooltipFooter
|
||||
id={configs[idx]?.getId() || idx.toString()}
|
||||
isPinned={isPinned}
|
||||
dismiss={dismiss}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@@ -200,8 +180,6 @@ function EntityMetrics<T>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={setTooltipPortalEl} style={{ display: 'contents' }} />
|
||||
|
||||
<div className={styles.metricsHeader}>
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
|
||||
@@ -5,11 +5,9 @@ import androidJavaMonitoringUrl from '@/assets/Logos/android-java-monitoring.svg
|
||||
import androidKotlinMonitoringUrl from '@/assets/Logos/android-kotlin-monitoring.svg';
|
||||
import anthropicApiMonitoringUrl from '@/assets/Logos/anthropic-api-monitoring.svg';
|
||||
import apacheDruidUrl from '@/assets/Logos/apache-druid.svg';
|
||||
import apacheUrl from '@/assets/Logos/apache.svg';
|
||||
import apiGatewayUrl from '@/assets/Logos/api-gateway.svg';
|
||||
import argocdUrl from '@/assets/Logos/argocd.svg';
|
||||
import aspnetUrl from '@/assets/Logos/aspnet.svg';
|
||||
import auth0Url from '@/assets/Logos/auth0.svg';
|
||||
import autogenUrl from '@/assets/Logos/autogen.svg';
|
||||
import awsAlbUrl from '@/assets/Logos/aws-alb.svg';
|
||||
import azureAppServiceUrl from '@/assets/Logos/azure-app-service.svg';
|
||||
@@ -29,7 +27,6 @@ import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
|
||||
import clickhouseUrl from '@/assets/Logos/clickhouse.svg';
|
||||
import cloudflareUrl from '@/assets/Logos/cloudflare.svg';
|
||||
import cloudwatchLogsUrl from '@/assets/Logos/cloudwatch-logs.svg';
|
||||
import cohereUrl from '@/assets/Logos/cohere.svg';
|
||||
import confluentKafkaUrl from '@/assets/Logos/confluent-kafka.svg';
|
||||
import convexLogoUrl from '@/assets/Logos/convex-logo.svg';
|
||||
import cppUrl from '@/assets/Logos/cpp.svg';
|
||||
@@ -42,13 +39,11 @@ import denoUrl from '@/assets/Logos/deno.svg';
|
||||
import dockerUrl from '@/assets/Logos/docker.svg';
|
||||
import documentLoadUrl from '@/assets/Logos/document-load.svg';
|
||||
import dotnetUrl from '@/assets/Logos/dotnet.svg';
|
||||
import dspyUrl from '@/assets/Logos/dspy.svg';
|
||||
import dynamodbUrl from '@/assets/Logos/dynamodb.svg';
|
||||
import ec2Url from '@/assets/Logos/ec2.svg';
|
||||
import ecsUrl from '@/assets/Logos/ecs.svg';
|
||||
import eksUrl from '@/assets/Logos/eks.svg';
|
||||
import elasticacheUrl from '@/assets/Logos/elasticache.svg';
|
||||
import elasticsearchUrl from '@/assets/Logos/elasticsearch.svg';
|
||||
import elbUrl from '@/assets/Logos/elb.svg';
|
||||
import elixirUrl from '@/assets/Logos/elixir.svg';
|
||||
import elkUrl from '@/assets/Logos/elk.svg';
|
||||
@@ -78,10 +73,8 @@ import grafanaUrl from '@/assets/Logos/grafana.svg';
|
||||
import graphqlUrl from '@/assets/Logos/graphql.svg';
|
||||
import grokUrl from '@/assets/Logos/grok.svg';
|
||||
import groqUrl from '@/assets/Logos/groq.svg';
|
||||
import haproxyUrl from '@/assets/Logos/haproxy.svg';
|
||||
import hasuraUrl from '@/assets/Logos/hasura.svg';
|
||||
import haystackUrl from '@/assets/Logos/haystack.svg';
|
||||
import hcpVaultUrl from '@/assets/Logos/hcp-vault.svg';
|
||||
import herokuUrl from '@/assets/Logos/heroku.svg';
|
||||
import honeycombUrl from '@/assets/Logos/honeycomb.svg';
|
||||
import hostmetricsUrl from '@/assets/Logos/hostmetrics.svg';
|
||||
@@ -99,7 +92,6 @@ import kafkaUrl from '@/assets/Logos/kafka.svg';
|
||||
import kubernetesUrl from '@/assets/Logos/kubernetes.svg';
|
||||
import lambdaUrl from '@/assets/Logos/lambda.svg';
|
||||
import langchainUrl from '@/assets/Logos/langchain.svg';
|
||||
import langflowUrl from '@/assets/Logos/langflow.svg';
|
||||
import langtraceUrl from '@/assets/Logos/langtrace.svg';
|
||||
import litellmUrl from '@/assets/Logos/litellm.svg';
|
||||
import livekitUrl from '@/assets/Logos/livekit.svg';
|
||||
@@ -125,7 +117,6 @@ import ollamaUrl from '@/assets/Logos/ollama.svg';
|
||||
import openaiUrl from '@/assets/Logos/openai.svg';
|
||||
import openclawUrl from '@/assets/Logos/openclaw.svg';
|
||||
import opencodeUrl from '@/assets/Logos/opencode.svg';
|
||||
import openWebuiUrl from '@/assets/Logos/open-webui.svg';
|
||||
import openlitUrl from '@/assets/Logos/openlit.svg';
|
||||
import openrouterUrl from '@/assets/Logos/openrouter.svg';
|
||||
import opentelemetryUrl from '@/assets/Logos/opentelemetry.svg';
|
||||
@@ -140,7 +131,6 @@ import pythonUrl from '@/assets/Logos/python.svg';
|
||||
import quarkusUrl from '@/assets/Logos/quarkus.svg';
|
||||
import quickstartUrl from '@/assets/Logos/quickstart.svg';
|
||||
import qwenUrl from '@/assets/Logos/qwen.svg';
|
||||
import rabbitmqUrl from '@/assets/Logos/rabbitmq.svg';
|
||||
import railwayUrl from '@/assets/Logos/railway.svg';
|
||||
import rdsUrl from '@/assets/Logos/rds.svg';
|
||||
import reactjsUrl from '@/assets/Logos/reactjs.svg';
|
||||
@@ -3947,58 +3937,6 @@ const onboardingConfigWithLinks = [
|
||||
],
|
||||
link: '/docs/claude-code-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'cohere',
|
||||
label: 'Cohere',
|
||||
imgUrl: cohereUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'cohere',
|
||||
'cohere api',
|
||||
'cohere logs',
|
||||
'cohere metrics',
|
||||
'cohere monitoring',
|
||||
'cohere observability',
|
||||
'cohere traces',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'logging',
|
||||
'logs',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'otel cohere integration',
|
||||
'telemetry',
|
||||
],
|
||||
link: '/docs/cohere-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'langflow',
|
||||
label: 'Langflow',
|
||||
imgUrl: langflowUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'langflow',
|
||||
'langflow logs',
|
||||
'langflow metrics',
|
||||
'langflow monitoring',
|
||||
'langflow observability',
|
||||
'langflow traces',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'logging',
|
||||
'logs',
|
||||
'low code ai',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'otel langflow integration',
|
||||
'telemetry',
|
||||
],
|
||||
link: '/docs/langflow-observability/',
|
||||
},
|
||||
{
|
||||
dataSource: 'deepseek-api',
|
||||
label: 'DeepSeek API',
|
||||
@@ -5545,32 +5483,12 @@ const onboardingConfigWithLinks = [
|
||||
relatedSearchKeywords: [
|
||||
'infrastructure',
|
||||
'traefik',
|
||||
'traefik access logs',
|
||||
'traefik logs',
|
||||
'traefik metrics',
|
||||
'traefik monitoring',
|
||||
'traefik observability',
|
||||
'traefik tracing',
|
||||
],
|
||||
link: '/docs/tutorial/traefik-observability/',
|
||||
question: {
|
||||
desc: 'Which Traefik signals do you want to send to SigNoz?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
key: 'traefik-metrics-traces',
|
||||
label: 'Metrics & Traces',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/tutorial/traefik-observability/',
|
||||
},
|
||||
{
|
||||
key: 'traefik-logs',
|
||||
label: 'Access Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/integrations/opentelemetry-traefik/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataSource: 'mongodb-atlas',
|
||||
@@ -5600,32 +5518,11 @@ const onboardingConfigWithLinks = [
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'mysql',
|
||||
'mysql error log',
|
||||
'mysql logs',
|
||||
'mysql metrics',
|
||||
'mysql monitoring',
|
||||
'mysql observability',
|
||||
'mysql slow query log',
|
||||
],
|
||||
link: '/docs/metrics-management/mysql-metrics/',
|
||||
question: {
|
||||
desc: 'Which MySQL signals do you want to send to SigNoz?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
key: 'mysql-metrics',
|
||||
label: 'Metrics',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/metrics-management/mysql-metrics/',
|
||||
},
|
||||
{
|
||||
key: 'mysql-logs',
|
||||
label: 'Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/integrations/opentelemetry-mysql/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataSource: 'jmx',
|
||||
@@ -6570,30 +6467,6 @@ const onboardingConfigWithLinks = [
|
||||
id: 'cert-manager',
|
||||
link: '/docs/infrastructure-monitoring/cert-manager/',
|
||||
},
|
||||
{
|
||||
dataSource: 'pgbouncer',
|
||||
label: 'PgBouncer',
|
||||
imgUrl: postgresqlUrl,
|
||||
tags: ['infrastructure monitoring', 'metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'connection pooler',
|
||||
'connection pooling',
|
||||
'database',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry pgbouncer',
|
||||
'pgbouncer',
|
||||
'pgbouncer metrics',
|
||||
'pgbouncer monitoring',
|
||||
'pgbouncer observability',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
],
|
||||
id: 'pgbouncer',
|
||||
link: '/docs/metrics-management/opentelemetry-pgbouncer/',
|
||||
},
|
||||
{
|
||||
dataSource: 'graphql',
|
||||
label: 'GraphQL',
|
||||
@@ -6618,28 +6491,6 @@ const onboardingConfigWithLinks = [
|
||||
id: 'graphql',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-graphql/',
|
||||
},
|
||||
{
|
||||
dataSource: 'opentelemetry-ebpf',
|
||||
label: 'OpenTelemetry eBPF (OBI)',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['apm/traces'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'auto instrumentation',
|
||||
'ebpf',
|
||||
'obi',
|
||||
'opentelemetry ebpf',
|
||||
'opentelemetry obi',
|
||||
'otel ebpf',
|
||||
'zero code instrumentation',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'traces',
|
||||
'tracing',
|
||||
],
|
||||
id: 'opentelemetry-ebpf',
|
||||
link: '/docs/instrumentation/opentelemetry-ebpf/',
|
||||
},
|
||||
{
|
||||
dataSource: 'railway',
|
||||
label: 'Railway',
|
||||
@@ -6662,54 +6513,6 @@ const onboardingConfigWithLinks = [
|
||||
id: 'railway',
|
||||
link: '/docs/integrations/outposts/railway/',
|
||||
},
|
||||
{
|
||||
dataSource: 'hcp-vault',
|
||||
label: 'HCP Vault',
|
||||
imgUrl: hcpVaultUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'hashicorp',
|
||||
'hashicorp vault',
|
||||
'hcp',
|
||||
'hcp vault',
|
||||
'hcp vault logs',
|
||||
'hcp vault monitoring',
|
||||
'hcp vault observability',
|
||||
'log forwarding',
|
||||
'logging',
|
||||
'logs',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'secrets management',
|
||||
'vault',
|
||||
],
|
||||
id: 'hcp-vault',
|
||||
link: '/docs/integrations/outposts/hcp-vault/',
|
||||
},
|
||||
{
|
||||
dataSource: 'auth0',
|
||||
label: 'Auth0',
|
||||
imgUrl: auth0Url,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'auth0',
|
||||
'auth0 logs',
|
||||
'auth0 monitoring',
|
||||
'auth0 observability',
|
||||
'authentication',
|
||||
'authorization',
|
||||
'identity',
|
||||
'log forwarding',
|
||||
'logging',
|
||||
'logs',
|
||||
'monitoring',
|
||||
'observability',
|
||||
],
|
||||
id: 'auth0',
|
||||
link: '/docs/integrations/outposts/auth0/',
|
||||
},
|
||||
{
|
||||
dataSource: 'aspnet-core-metrics',
|
||||
label: 'ASP.NET Core Metrics',
|
||||
@@ -6829,164 +6632,5 @@ const onboardingConfigWithLinks = [
|
||||
id: 'apache-druid',
|
||||
link: '/docs/integrations/opentelemetry-apache-druid/',
|
||||
},
|
||||
{
|
||||
dataSource: 'apache',
|
||||
label: 'Apache HTTP Server',
|
||||
imgUrl: apacheUrl,
|
||||
tags: ['infrastructure monitoring', 'metrics', 'logs'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'apache',
|
||||
'apache access logs',
|
||||
'apache error logs',
|
||||
'apache http server',
|
||||
'apache httpd',
|
||||
'apache logs',
|
||||
'apache metrics',
|
||||
'apache monitoring',
|
||||
'apache observability',
|
||||
'httpd',
|
||||
'infrastructure monitoring',
|
||||
'logs',
|
||||
'metrics',
|
||||
'mod_status',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry apache',
|
||||
'web server',
|
||||
],
|
||||
id: 'apache',
|
||||
link: '/docs/integrations/opentelemetry-apache/',
|
||||
},
|
||||
{
|
||||
dataSource: 'haproxy',
|
||||
label: 'HAProxy',
|
||||
imgUrl: haproxyUrl,
|
||||
tags: ['infrastructure monitoring', 'metrics', 'logs'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'haproxy',
|
||||
'haproxy logs',
|
||||
'haproxy metrics',
|
||||
'haproxy monitoring',
|
||||
'haproxy observability',
|
||||
'infrastructure monitoring',
|
||||
'load balancer',
|
||||
'logs',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry haproxy',
|
||||
'proxy',
|
||||
'reverse proxy',
|
||||
'syslog',
|
||||
],
|
||||
id: 'haproxy',
|
||||
link: '/docs/integrations/opentelemetry-haproxy/',
|
||||
},
|
||||
{
|
||||
dataSource: 'elasticsearch',
|
||||
label: 'Elasticsearch',
|
||||
imgUrl: elasticsearchUrl,
|
||||
tags: ['database'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'cluster health',
|
||||
'database',
|
||||
'elastic',
|
||||
'elasticsearch',
|
||||
'elasticsearch logs',
|
||||
'elasticsearch metrics',
|
||||
'elasticsearch monitoring',
|
||||
'elasticsearch observability',
|
||||
'logs',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry elasticsearch',
|
||||
'search engine',
|
||||
],
|
||||
id: 'elasticsearch',
|
||||
link: '/docs/integrations/opentelemetry-elasticsearch/',
|
||||
},
|
||||
{
|
||||
dataSource: 'rabbitmq',
|
||||
label: 'RabbitMQ',
|
||||
imgUrl: rabbitmqUrl,
|
||||
tags: ['Messaging Queues'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'amqp',
|
||||
'broker',
|
||||
'logs',
|
||||
'messaging',
|
||||
'messaging queues',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry rabbitmq',
|
||||
'queues',
|
||||
'rabbitmq',
|
||||
'rabbitmq logs',
|
||||
'rabbitmq metrics',
|
||||
'rabbitmq monitoring',
|
||||
'rabbitmq observability',
|
||||
],
|
||||
id: 'rabbitmq',
|
||||
link: '/docs/integrations/opentelemetry-rabbitmq/',
|
||||
},
|
||||
{
|
||||
dataSource: 'open-webui',
|
||||
label: 'Open WebUI',
|
||||
imgUrl: openWebuiUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'logs',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'open webui',
|
||||
'open webui logs',
|
||||
'open webui metrics',
|
||||
'open webui monitoring',
|
||||
'open webui observability',
|
||||
'open webui traces',
|
||||
'openlit',
|
||||
'openwebui',
|
||||
'otel open webui integration',
|
||||
'self hosted chat ui',
|
||||
'traces',
|
||||
'tracing',
|
||||
],
|
||||
id: 'open-webui',
|
||||
link: '/docs/open-webui-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'dspy',
|
||||
label: 'DSPy',
|
||||
imgUrl: dspyUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'dspy',
|
||||
'dspy monitoring',
|
||||
'dspy observability',
|
||||
'dspy traces',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'openinference',
|
||||
'otel dspy integration',
|
||||
'prompt optimization',
|
||||
'traces',
|
||||
'tracing',
|
||||
],
|
||||
id: 'dspy',
|
||||
link: '/docs/dspy-observability/',
|
||||
},
|
||||
];
|
||||
export default onboardingConfigWithLinks;
|
||||
|
||||
@@ -51,7 +51,6 @@ export default function TooltipPlugin({
|
||||
canPinTooltip = false,
|
||||
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
|
||||
onClick,
|
||||
portalRoot: portalRootProp,
|
||||
}: TooltipPluginProps): JSX.Element | null {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rafId = useRef<number | null>(null);
|
||||
@@ -60,9 +59,7 @@ export default function TooltipPlugin({
|
||||
const renderRef = useRef(render);
|
||||
renderRef.current = render;
|
||||
const [portalRoot, setPortalRoot] = useState<HTMLElement>(
|
||||
portalRootProp ??
|
||||
(document.fullscreenElement as HTMLElement) ??
|
||||
document.body,
|
||||
(document.fullscreenElement as HTMLElement) ?? document.body,
|
||||
);
|
||||
|
||||
// React-managed snapshot of what should be rendered. The controller
|
||||
@@ -467,12 +464,8 @@ export default function TooltipPlugin({
|
||||
}, [config]);
|
||||
|
||||
const resolvePortalRoot = useCallback((): void => {
|
||||
setPortalRoot(
|
||||
portalRootProp ??
|
||||
(document.fullscreenElement as HTMLElement) ??
|
||||
document.body,
|
||||
);
|
||||
}, [portalRootProp]);
|
||||
setPortalRoot((document.fullscreenElement as HTMLElement) ?? document.body);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect((): (() => void) => {
|
||||
resolvePortalRoot();
|
||||
|
||||
@@ -67,7 +67,6 @@ export interface TooltipPluginProps {
|
||||
pinnedTooltipElement?: (clickData: ChartClickData) => ReactNode;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
portalRoot?: HTMLElement | null;
|
||||
}
|
||||
|
||||
export interface ChartClickData {
|
||||
|
||||
@@ -648,176 +648,3 @@ describe('getQueryContextAtCursor - trailing dot in key/value', () => {
|
||||
expect(ctx.keyToken).toBe('k8s.namespace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueryContextAtCursor - partial operator', () => {
|
||||
it('treats text after an incomplete key as an operator prefix', () => {
|
||||
const q = 'service.name c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.isInKey).toBe(false);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('c');
|
||||
expect(ctx.currentPair).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
key: 'service.name',
|
||||
operator: 'c',
|
||||
position: expect.objectContaining({
|
||||
operatorStart: 13,
|
||||
operatorEnd: 13,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the operator context while completing contains', () => {
|
||||
const q = 'service.name cont';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('cont');
|
||||
});
|
||||
|
||||
it('treats cursor mid-token as operator context', () => {
|
||||
const q = 'service.name cont';
|
||||
// cursor sits between "con" and "t" — user still typing the operator
|
||||
const ctx = getQueryContextAtCursor(q, 15);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.isInKey).toBe(false);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('cont');
|
||||
});
|
||||
|
||||
it('keeps operator context when an AND conjunction precedes the pair', () => {
|
||||
const q = 'a = 1 AND service.name c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.isInKey).toBe(false);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('c');
|
||||
expect(ctx.currentPair).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
key: 'service.name',
|
||||
operator: 'c',
|
||||
position: expect.objectContaining({
|
||||
operatorStart: 23,
|
||||
operatorEnd: 23,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps operator context when an open parenthesis precedes the pair', () => {
|
||||
const q = '(service.name c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.isInKey).toBe(false);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('c');
|
||||
});
|
||||
|
||||
it('re-glues a partial operator that follows a NOT negation', () => {
|
||||
const q = 'service.name NOT c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.isInKey).toBe(false);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('NOT c');
|
||||
// operatorStart points at the partial operator (post-NOT), not at the
|
||||
// negation — so suggestion selection only replaces the partial, never
|
||||
// the user's typed NOT.
|
||||
expect(ctx.currentPair).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
key: 'service.name',
|
||||
operator: 'NOT c',
|
||||
hasNegation: true,
|
||||
position: expect.objectContaining({
|
||||
negationStart: 13,
|
||||
negationEnd: 15,
|
||||
operatorStart: 17,
|
||||
operatorEnd: 17,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-glues a multi-character partial operator after NOT', () => {
|
||||
const q = 'service.name NOT lik';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('NOT lik');
|
||||
expect(ctx.currentPair?.hasNegation).toBe(true);
|
||||
});
|
||||
|
||||
it('re-glues an uppercase partial operator after NOT', () => {
|
||||
const q = 'service.name NOT EXI';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('NOT EXI');
|
||||
expect(ctx.currentPair?.hasNegation).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves original NOT casing in the operator text', () => {
|
||||
const q = 'service.name not c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('not c');
|
||||
expect(ctx.currentPair?.hasNegation).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates extra whitespace between NOT and the partial operator', () => {
|
||||
const q = 'service.name NOT c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
// Display text uses a canonical single space between NOT and the
|
||||
// partial, regardless of how many spaces the user typed.
|
||||
expect(ctx.operatorToken).toBe('NOT c');
|
||||
expect(ctx.currentPair?.hasNegation).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps operator context for NOT-prefixed partial inside parentheses', () => {
|
||||
const q = '(service.name NOT c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('NOT c');
|
||||
expect(ctx.currentPair?.hasNegation).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps operator context for NOT-prefixed partial after an AND conjunction', () => {
|
||||
const q = 'a = 1 AND service.name NOT c';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('NOT c');
|
||||
expect(ctx.currentPair?.hasNegation).toBe(true);
|
||||
});
|
||||
|
||||
it('re-glues the most recent incomplete pair when three partial tokens are typed', () => {
|
||||
// Pins documented behavior: with two trailing partial pairs (`c` and
|
||||
// `k`), the heuristic pairs the most recent two — `c` becomes the
|
||||
// key, `k` becomes the partial operator. The earlier `service.name`
|
||||
// is dropped from the current pair view.
|
||||
const q = 'service.name c k';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
|
||||
expect(ctx.isInOperator).toBe(true);
|
||||
expect(ctx.keyToken).toBe('c');
|
||||
expect(ctx.operatorToken).toBe('k');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -605,98 +605,6 @@ export function getQueryContextAtCursor(
|
||||
queryPairs,
|
||||
);
|
||||
|
||||
// Re-glue a partial operator that ANTLR has lexed as a second key.
|
||||
//
|
||||
// When the user types `service.name c` (or `service.name NOT c`), the
|
||||
// lexer sees two KEY tokens (`service.name`, `c`) instead of a key +
|
||||
// partial operator, so `extractQueryPairs` emits two consecutive
|
||||
// key-only incomplete pairs. Downstream, that makes the dropdown
|
||||
// suggest keys when it should be suggesting operators.
|
||||
//
|
||||
// Detect that pattern — a previous incomplete key-only pair followed
|
||||
// by another incomplete key-only `currentPair`, separated only by
|
||||
// whitespace (or by a negation token attached to the previous pair) —
|
||||
// and rebuild a single synthetic pair where the previous pair's key
|
||||
// is the key and the current pair's key is treated as the partial
|
||||
// operator. The synthetic pair inherits the previous pair's negation
|
||||
// flag and positions via the spread, so `NOT <partial>` propagates
|
||||
// correctly to consumers.
|
||||
const previousIncompletePair = queryPairs
|
||||
.filter(
|
||||
(pair) =>
|
||||
!pair.isComplete &&
|
||||
!!pair.key &&
|
||||
!pair.operator &&
|
||||
pair.position.keyEnd < (currentPair?.position.keyStart ?? cursorIndex),
|
||||
)
|
||||
.sort((a, b) => b.position.keyEnd - a.position.keyEnd)[0];
|
||||
|
||||
if (
|
||||
previousIncompletePair &&
|
||||
currentPair &&
|
||||
currentPair !== previousIncompletePair &&
|
||||
!currentPair.operator &&
|
||||
currentPair.position.keyStart > previousIncompletePair.position.keyEnd
|
||||
) {
|
||||
const negationStart = previousIncompletePair.position.negationStart ?? 0;
|
||||
const negationEnd = previousIncompletePair.position.negationEnd ?? 0;
|
||||
const negationAfterKey =
|
||||
previousIncompletePair.hasNegation &&
|
||||
negationStart > previousIncompletePair.position.keyEnd;
|
||||
const gapStart = negationAfterKey
|
||||
? negationEnd + 1
|
||||
: previousIncompletePair.position.keyEnd + 1;
|
||||
const textBetweenPairs = query.slice(
|
||||
gapStart,
|
||||
currentPair.position.keyStart,
|
||||
);
|
||||
|
||||
if (textBetweenPairs.trim() === '') {
|
||||
// The replacement range (operatorStart/operatorEnd) must point
|
||||
// at the partial operator only, NOT the leading negation.
|
||||
// Consumers like QuerySearch use it to splice the chosen
|
||||
// suggestion in-place, so including the negation would let a
|
||||
// `NOT lik` -> `LIKE` selection erase the user's typed `NOT`.
|
||||
// Matches the convention used for complete pairs in
|
||||
// extractQueryPairs, where operatorStart starts after the
|
||||
// negation token.
|
||||
const operatorStart = currentPair.position.keyStart;
|
||||
const operatorEnd = currentPair.position.keyEnd;
|
||||
const partialOperator = query.slice(operatorStart, operatorEnd + 1);
|
||||
const operatorText = negationAfterKey
|
||||
? `${query.slice(negationStart, negationEnd + 1)} ${partialOperator}`
|
||||
: partialOperator;
|
||||
|
||||
return {
|
||||
tokenType: -1,
|
||||
text: '',
|
||||
start: cursorIndex,
|
||||
stop: cursorIndex,
|
||||
currentToken: operatorText,
|
||||
isInKey: false,
|
||||
isInNegation: false,
|
||||
isInOperator: true,
|
||||
isInValue: false,
|
||||
isInConjunction: false,
|
||||
isInFunction: false,
|
||||
isInParenthesis: false,
|
||||
isInBracketList: false,
|
||||
keyToken: previousIncompletePair.key,
|
||||
operatorToken: operatorText,
|
||||
queryPairs,
|
||||
currentPair: {
|
||||
...previousIncompletePair,
|
||||
operator: operatorText,
|
||||
position: {
|
||||
...previousIncompletePair.position,
|
||||
operatorStart,
|
||||
operatorEnd,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cursor is within any of the specific context boundaries
|
||||
// FIXED: Include the case where the cursor is exactly at the end of a boundary
|
||||
const isInKeyBoundary =
|
||||
|
||||
8
go.mod
@@ -223,7 +223,7 @@ require (
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/cel-go v0.29.0 // indirect
|
||||
github.com/google/cel-go v0.28.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.18.0 // indirect
|
||||
@@ -392,9 +392,9 @@ require (
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478
|
||||
google.golang.org/grpc v1.82.1 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
gopkg.in/telebot.v3 v3.3.8 // indirect
|
||||
k8s.io/client-go v0.35.3 // indirect
|
||||
k8s.io/klog/v2 v2.140.0 // indirect
|
||||
|
||||
20
go.sum
@@ -247,8 +247,8 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH
|
||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
github.com/coder/quartz v0.3.0 h1:bUoSEJ77NBfKtUqv6CPSC0AS8dsjqAqqAv7bN02m1mg=
|
||||
github.com/coder/quartz v0.3.0/go.mod h1:BgE7DOj/8NfvRgvKw0jPLDQH/2Lya2kxcTaNJ8X0rZk=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
@@ -507,8 +507,8 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4=
|
||||
github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
|
||||
github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc=
|
||||
github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
|
||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
@@ -1856,10 +1856,10 @@ google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP
|
||||
google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc=
|
||||
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
@@ -1892,8 +1892,8 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5
|
||||
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
|
||||
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
|
||||
@@ -14,592 +14,371 @@ import (
|
||||
)
|
||||
|
||||
func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/dashboards", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.ListV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListDashboardsV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboards (v2)",
|
||||
Description: "Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).",
|
||||
Request: nil,
|
||||
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListV2), handler.OpenAPIDef{
|
||||
ID: "ListDashboardsV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboards (v2)",
|
||||
Description: "Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).",
|
||||
Request: nil,
|
||||
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardV2),
|
||||
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/v2/users/me/dashboards", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.ListForUserV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListDashboardsForUserV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboards for the current user (v2)",
|
||||
Description: "Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.",
|
||||
Request: nil,
|
||||
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardForUserV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/me/dashboards", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListForUserV2), handler.OpenAPIDef{
|
||||
ID: "ListDashboardsForUserV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboards for the current user (v2)",
|
||||
Description: "Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.",
|
||||
Request: nil,
|
||||
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardForUserV2),
|
||||
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/v2/dashboards", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.CreateV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create dashboard (v2)",
|
||||
Description: "This endpoint creates a dashboard in the v2 format that follows Perses spec.",
|
||||
Request: new(dashboardtypes.PostableDashboardV2),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
// TODO: add http.StatusConflict once the dashboard name unique index is added.
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateV2), handler.OpenAPIDef{
|
||||
ID: "CreateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create dashboard (v2)",
|
||||
Description: "This endpoint creates a dashboard in the v2 format that follows Perses spec.",
|
||||
Request: new(dashboardtypes.PostableDashboardV2),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
// TODO: add http.StatusConflict once the dashboard name unique index is added.
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Two defs: the clone reads the source dashboard and creates a new one, so
|
||||
// both read on the source id and create on the collection are checked.
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/clone", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.CloneV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CloneDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Clone dashboard (v2)",
|
||||
Description: "This endpoint clones an existing v2-shape dashboard. User and integration dashboards can be cloned; system dashboards are rejected. The clone keeps the source's display name, panels, and tags, but gets a freshly generated unique internal name and is always created as an unlocked user dashboard owned by the caller.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead),
|
||||
coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbCreate),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CloneV2), handler.OpenAPIDef{
|
||||
ID: "CloneDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Clone dashboard (v2)",
|
||||
Description: "This endpoint clones an existing v2-shape dashboard. User and integration dashboards can be cloned; system dashboards are rejected. The clone keeps the source's display name, panels, and tags, but gets a freshly generated unique internal name and is always created as an unlocked user dashboard owned by the caller.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/migrate", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.MigrateV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "MigrateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Migrate dashboard to v2",
|
||||
Description: "This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/migrate", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.MigrateV2), handler.OpenAPIDef{
|
||||
ID: "MigrateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Migrate dashboard to v2",
|
||||
Description: "This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.GetV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get dashboard (v2)",
|
||||
Description: "This endpoint returns a v2-shape dashboard.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
|
||||
ID: "GetDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get dashboard (v2)",
|
||||
Description: "This endpoint returns a v2-shape dashboard.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
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/v2/dashboards/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UpdateV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update dashboard (v2)",
|
||||
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
|
||||
Request: new(dashboardtypes.UpdatableDashboardV2),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateV2), handler.OpenAPIDef{
|
||||
ID: "UpdateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update dashboard (v2)",
|
||||
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
|
||||
Request: new(dashboardtypes.UpdatableDashboardV2),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.PatchV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "PatchDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Patch dashboard (v2)",
|
||||
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.",
|
||||
Request: new(dashboardtypes.PatchableDashboardV2),
|
||||
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
|
||||
// but our OpenAPI generator only reflects schemas for content types it
|
||||
// understands (application/json, form-urlencoded, multipart) — anything
|
||||
// else degrades to `type: string`. Declaring application/json here keeps
|
||||
// the array-of-ops schema visible to spec consumers; the runtime decoder
|
||||
// parses JSON regardless of the request's actual Content-Type header.
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.PatchV2), handler.OpenAPIDef{
|
||||
ID: "PatchDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Patch dashboard (v2)",
|
||||
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.",
|
||||
Request: new(dashboardtypes.PatchableDashboardV2),
|
||||
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
|
||||
// but our OpenAPI generator only reflects schemas for content types it
|
||||
// understands (application/json, form-urlencoded, multipart) — anything
|
||||
// else degrades to `type: string`. Declaring application/json here keeps
|
||||
// the array-of-ops schema visible to spec consumers; the runtime decoder
|
||||
// parses JSON regardless of the request's actual Content-Type header.
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.DeleteV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete dashboard (v2)",
|
||||
Description: "This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.DeleteV2), handler.OpenAPIDef{
|
||||
ID: "DeleteDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete dashboard (v2)",
|
||||
Description: "This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.LockV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "LockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Lock dashboard (v2)",
|
||||
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
|
||||
ID: "LockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Lock dashboard (v2)",
|
||||
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UnlockV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UnlockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Unlock dashboard (v2)",
|
||||
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UnlockV2), handler.OpenAPIDef{
|
||||
ID: "UnlockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Unlock dashboard (v2)",
|
||||
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pinning mutates the calling user's pin list, not the dashboard, so it rides
|
||||
// on the collection-level list permission rather than a per-dashboard check.
|
||||
// The id is still extracted, for audit.
|
||||
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.PinV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "PinDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Pin a dashboard for the current user (v2)",
|
||||
Description: "Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
// ViewAccess: pinning only mutates the calling user's pin list, not the
|
||||
// dashboard itself — anyone who can view a dashboard can bookmark it.
|
||||
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.PinV2), handler.OpenAPIDef{
|
||||
ID: "PinDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Pin a dashboard for the current user (v2)",
|
||||
Description: "Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UnpinV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UnpinDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Unpin a dashboard for the current user (v2)",
|
||||
Description: "Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.UnpinV2), handler.OpenAPIDef{
|
||||
ID: "UnpinDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Unpin a dashboard for the current user (v2)",
|
||||
Description: "Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Saved views hold dashboard listing state (query, sort, order) rather than any
|
||||
// one dashboard, so all four ride on the collection-level list permission.
|
||||
if err := router.Handle("/api/v2/dashboard_views", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.ListViews, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListDashboardViews",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboard saved views",
|
||||
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboard_views", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListViews), handler.OpenAPIDef{
|
||||
ID: "ListDashboardViews",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboard saved views",
|
||||
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboard_views", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.CreateView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create dashboard saved view",
|
||||
Description: "Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.",
|
||||
Request: new(dashboardtypes.PostableDashboardView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.DashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboard_views", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateView), handler.OpenAPIDef{
|
||||
ID: "CreateDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create dashboard saved view",
|
||||
Description: "Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.",
|
||||
Request: new(dashboardtypes.PostableDashboardView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.DashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UpdateView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update dashboard saved view",
|
||||
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
|
||||
Request: new(dashboardtypes.UpdatableDashboardView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.DashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateView), handler.OpenAPIDef{
|
||||
ID: "UpdateDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update dashboard saved view",
|
||||
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
|
||||
Request: new(dashboardtypes.UpdatableDashboardView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.DashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.DeleteView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete dashboard saved view",
|
||||
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.DeleteView), handler.OpenAPIDef{
|
||||
ID: "DeleteDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete dashboard saved view",
|
||||
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.CreatePublic, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreatePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create public dashboard",
|
||||
Description: "This endpoint creates public sharing config and enables public sharing of the dashboard",
|
||||
Request: new(dashboardtypes.PostablePublicDashboard),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
|
||||
ID: "CreatePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create public dashboard",
|
||||
Description: "This endpoint creates public sharing config and enables public sharing of the dashboard",
|
||||
Request: new(dashboardtypes.PostablePublicDashboard),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.GetPublic, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetPublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get public dashboard",
|
||||
Description: "This endpoint returns public sharing config for a dashboard",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettablePublicDasbhboard),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.GetPublic), handler.OpenAPIDef{
|
||||
ID: "GetPublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get public dashboard",
|
||||
Description: "This endpoint returns public sharing config for a dashboard",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettablePublicDasbhboard),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UpdatePublic, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdatePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update public dashboard",
|
||||
Description: "This endpoint updates the public sharing config for a dashboard",
|
||||
Request: new(dashboardtypes.UpdatablePublicDashboard),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.UpdatePublic), handler.OpenAPIDef{
|
||||
ID: "UpdatePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update public dashboard",
|
||||
Description: "This endpoint updates the public sharing config for a dashboard",
|
||||
Request: new(dashboardtypes.UpdatablePublicDashboard),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.DeletePublic, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeletePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete public dashboard",
|
||||
Description: "This endpoint deletes the public sharing config and disables the public sharing of a dashboard",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.DeletePublic), handler.OpenAPIDef{
|
||||
ID: "DeletePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete public dashboard",
|
||||
Description: "This endpoint deletes the public sharing config and disables the public sharing of a dashboard",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -983,78 +982,25 @@ func assignIfEmpty(s *string, value string) {
|
||||
// MatchingFieldKeys returns the field keys from the map that match the given key,
|
||||
// honoring any context/data type the user specified.
|
||||
func MatchingFieldKeys(field *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
|
||||
selector := telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: field.Signal,
|
||||
FieldContext: field.FieldContext,
|
||||
}
|
||||
members := semconv.Members(semconv.KindAttribute, selector)
|
||||
isFamily := len(members) > 1
|
||||
fieldKeysForName := make([]*telemetrytypes.TelemetryFieldKey, 0)
|
||||
indexByIdentity := make(map[string]int)
|
||||
fieldKeysForName := []*telemetrytypes.TelemetryFieldKey{}
|
||||
|
||||
appendMatches := func(lookupName string, memberName string, contextAlreadyMatched bool) {
|
||||
for _, item := range fieldKeys[lookupName] {
|
||||
if !contextAlreadyMatched && field.FieldContext != telemetrytypes.FieldContextUnspecified && field.FieldContext != item.FieldContext {
|
||||
continue
|
||||
}
|
||||
if field.FieldDataType != telemetrytypes.FieldDataTypeUnspecified && field.FieldDataType != item.FieldDataType {
|
||||
continue
|
||||
}
|
||||
|
||||
// A wildcard lookup may have found a same-named field in a scope where
|
||||
// this family does not apply. Keep exact names, but reject cross-member
|
||||
// matches outside the generated family scope.
|
||||
if memberName != field.Name {
|
||||
itemSelector := telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: item.Signal,
|
||||
FieldContext: item.FieldContext,
|
||||
}
|
||||
if !slices.Contains(semconv.Members(semconv.KindAttribute, itemSelector), memberName) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
physicalMembers := item.SemconvMembers
|
||||
if len(physicalMembers) == 0 {
|
||||
physicalMembers = []string{memberName}
|
||||
}
|
||||
identity := item.Signal.StringValue() + ";" + item.FieldContext.StringValue() + ";" + item.FieldDataType.StringValue()
|
||||
if isFamily {
|
||||
if index, found := indexByIdentity[identity]; found {
|
||||
for _, physicalMember := range physicalMembers {
|
||||
if !slices.Contains(fieldKeysForName[index].SemconvMembers, physicalMember) {
|
||||
fieldKeysForName[index].SemconvMembers = append(fieldKeysForName[index].SemconvMembers, physicalMember)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
indexByIdentity[identity] = len(fieldKeysForName)
|
||||
}
|
||||
resolved := *item
|
||||
// The requested spelling is the response identity. Field mappers use
|
||||
// it to resolve the available family members current-first.
|
||||
if isFamily {
|
||||
resolved.Name = field.Name
|
||||
resolved.SemconvMembers = slices.Clone(physicalMembers)
|
||||
}
|
||||
fieldKeysForName = append(fieldKeysForName, &resolved)
|
||||
// match by name; keep items whose context and data type match (unspecified matches any)
|
||||
for _, item := range fieldKeys[field.Name] {
|
||||
if (field.FieldContext == telemetrytypes.FieldContextUnspecified || field.FieldContext == item.FieldContext) &&
|
||||
(field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || field.FieldDataType == item.FieldDataType) {
|
||||
fieldKeysForName = append(fieldKeysForName, item)
|
||||
}
|
||||
}
|
||||
|
||||
// Members are current-first, so metadata from the current key wins when
|
||||
// both spellings describe the same signal/context/type.
|
||||
for _, member := range members {
|
||||
appendMatches(member, member, false)
|
||||
}
|
||||
|
||||
// A context may have been split off a name that legitimately contained it
|
||||
// (e.g. `attribute.key`); preserve that historical alternate reading for
|
||||
// every family member.
|
||||
// A context may have been split off a name that legitimately contained it (e.g.
|
||||
// `attribute.key`); also look up the context-prefixed name so both readings resolve.
|
||||
if field.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
for _, member := range members {
|
||||
appendMatches(fmt.Sprintf("%s.%s", field.FieldContext.StringValue(), member), member, true)
|
||||
contextPrefixedFieldName := fmt.Sprintf("%s.%s", field.FieldContext.StringValue(), field.Name)
|
||||
for _, item := range fieldKeys[contextPrefixedFieldName] {
|
||||
// Context already matched via the lookup key; only data type needs checking.
|
||||
if field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || item.FieldDataType == field.FieldDataType {
|
||||
fieldKeysForName = append(fieldKeysForName, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPrepareWhereClause_EmptyVariableList ensures PrepareWhereClause errors when a variable has an empty list value.
|
||||
@@ -686,53 +685,6 @@ func TestVisitKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchingFieldKeysResolvesSemconvFamily(t *testing.T) {
|
||||
current := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Description: "current metadata",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
old := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
Description: "old metadata",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
current.Name: {current},
|
||||
old.Name: {old},
|
||||
}
|
||||
|
||||
for _, requestedName := range []string{current.Name, old.Name} {
|
||||
requested := telemetrytypes.NewTelemetryFieldKey(
|
||||
requestedName,
|
||||
telemetrytypes.FieldContextResource,
|
||||
telemetrytypes.FieldDataTypeString,
|
||||
)
|
||||
matches := MatchingFieldKeys(requested, fieldKeys)
|
||||
require.Len(t, matches, 1, "family lookup must return one field before its metadata is inspected")
|
||||
assert.Equal(t, requestedName, matches[0].Name)
|
||||
assert.Equal(t, "current metadata", matches[0].Description)
|
||||
assert.Equal(t, []string{current.Name, old.Name}, matches[0].SemconvMembers)
|
||||
}
|
||||
|
||||
// A current-name query still resolves when metadata has seen only the old
|
||||
// spelling. The returned name remains the request identity.
|
||||
requested := telemetrytypes.NewTelemetryFieldKey(
|
||||
current.Name,
|
||||
telemetrytypes.FieldContextResource,
|
||||
telemetrytypes.FieldDataTypeString,
|
||||
)
|
||||
matches := MatchingFieldKeys(requested, map[string][]*telemetrytypes.TelemetryFieldKey{old.Name: {old}})
|
||||
require.Len(t, matches, 1, "family lookup must return one field before its metadata is inspected")
|
||||
assert.Equal(t, current.Name, matches[0].Name)
|
||||
assert.Equal(t, "old metadata", matches[0].Description)
|
||||
assert.Equal(t, []string{old.Name}, matches[0].SemconvMembers)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestVisitComparison
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// Package semconv resolves historical OpenTelemetry semantic-convention names
|
||||
// into logical field families.
|
||||
package semconv
|
||||
@@ -1,22 +0,0 @@
|
||||
// Code generated by scripts/semconv. DO NOT EDIT.
|
||||
|
||||
package semconv
|
||||
|
||||
var families = []Family{
|
||||
{
|
||||
Current: "db.system.name",
|
||||
Old: []string{"db.system"},
|
||||
Kind: KindAttribute,
|
||||
Contexts: nil,
|
||||
Signals: nil,
|
||||
ApplyToMetrics: nil,
|
||||
},
|
||||
{
|
||||
Current: "deployment.environment.name",
|
||||
Old: []string{"deployment.environment"},
|
||||
Kind: KindAttribute,
|
||||
Contexts: nil,
|
||||
Signals: nil,
|
||||
ApplyToMetrics: nil,
|
||||
},
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package semconv
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
//go:generate go run ../../scripts/semconv
|
||||
|
||||
// Kind identifies whether a family describes an attribute or a metric name.
|
||||
type Kind struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
// Family is one logical telemetry field. Old is ordered from the most recent
|
||||
// predecessor to the oldest one and therefore also defines fallback order.
|
||||
type Family struct {
|
||||
Current string
|
||||
Old []string
|
||||
Kind Kind
|
||||
Contexts []telemetrytypes.FieldContext
|
||||
Signals []telemetrytypes.Signal
|
||||
ApplyToMetrics []string
|
||||
ValueMap map[string]string
|
||||
}
|
||||
|
||||
var (
|
||||
KindAttribute = Kind{String: valuer.NewString("attribute")}
|
||||
KindMetric = Kind{String: valuer.NewString("metric")}
|
||||
)
|
||||
|
||||
var memberToFamilies, familyMembers = buildIndexes()
|
||||
|
||||
// Enum returns the acceptable values for Kind.
|
||||
func (Kind) Enum() []any {
|
||||
return []any{KindAttribute, KindMetric}
|
||||
}
|
||||
|
||||
// Lookup returns the enabled family containing selector.Name for kind. The
|
||||
// slices and map in the returned value reference immutable generated data and
|
||||
// must be treated as read-only. Use All when a mutable copy is required.
|
||||
func Lookup(kind Kind, selector telemetrytypes.FieldKeySelector) (Family, bool) {
|
||||
idx, ok := lookupIndex(kind, selector)
|
||||
if !ok {
|
||||
return Family{}, false
|
||||
}
|
||||
return families[idx], true
|
||||
}
|
||||
|
||||
// Members returns the current name first, followed by historical names in
|
||||
// fallback order. For enabled families the returned precomputed slice is
|
||||
// read-only. A name outside an enabled family is returned unchanged.
|
||||
func Members(kind Kind, selector telemetrytypes.FieldKeySelector) []string {
|
||||
idx, ok := lookupIndex(kind, selector)
|
||||
if !ok {
|
||||
return []string{selector.Name}
|
||||
}
|
||||
return familyMembers[idx]
|
||||
}
|
||||
|
||||
// Current returns the current name for selector.Name, or the input name when
|
||||
// it does not belong to an enabled family.
|
||||
func Current(kind Kind, selector telemetrytypes.FieldKeySelector) string {
|
||||
idx, ok := lookupIndex(kind, selector)
|
||||
if !ok {
|
||||
return selector.Name
|
||||
}
|
||||
return families[idx].Current
|
||||
}
|
||||
|
||||
// All returns a defensive copy of every enabled family.
|
||||
func All() []Family {
|
||||
result := make([]Family, len(families))
|
||||
for i := range families {
|
||||
result[i] = cloneFamily(families[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildIndexes() (map[string][]int, [][]string) {
|
||||
index := make(map[string][]int)
|
||||
members := make([][]string, len(families))
|
||||
for i, family := range families {
|
||||
members[i] = make([]string, 0, len(family.Old)+1)
|
||||
members[i] = append(members[i], family.Current)
|
||||
members[i] = append(members[i], family.Old...)
|
||||
index[family.Current] = append(index[family.Current], i)
|
||||
for _, old := range family.Old {
|
||||
index[old] = append(index[old], i)
|
||||
}
|
||||
}
|
||||
return index, members
|
||||
}
|
||||
|
||||
func lookupIndex(kind Kind, selector telemetrytypes.FieldKeySelector) (int, bool) {
|
||||
for _, idx := range memberToFamilies[selector.Name] {
|
||||
if matchesSelector(families[idx], kind, selector) {
|
||||
return idx, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func matchesSelector(family Family, kind Kind, selector telemetrytypes.FieldKeySelector) bool {
|
||||
if family.Kind != kind {
|
||||
return false
|
||||
}
|
||||
|
||||
if selector.Signal != telemetrytypes.SignalUnspecified && len(family.Signals) > 0 {
|
||||
if !slices.Contains(family.Signals, selector.Signal) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if selector.FieldContext != telemetrytypes.FieldContextUnspecified && len(family.Contexts) > 0 {
|
||||
if !slices.Contains(family.Contexts, selector.FieldContext) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if selector.Signal == telemetrytypes.SignalMetrics && len(family.ApplyToMetrics) > 0 {
|
||||
if selector.MetricContext == nil {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(family.ApplyToMetrics, selector.MetricContext.MetricName)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func cloneFamily(family Family) Family {
|
||||
valueMap := family.ValueMap
|
||||
family.Old = slices.Clone(family.Old)
|
||||
family.Contexts = slices.Clone(family.Contexts)
|
||||
family.Signals = slices.Clone(family.Signals)
|
||||
family.ApplyToMetrics = slices.Clone(family.ApplyToMetrics)
|
||||
if valueMap != nil {
|
||||
family.ValueMap = make(map[string]string, len(valueMap))
|
||||
for old, current := range valueMap {
|
||||
family.ValueMap[old] = current
|
||||
}
|
||||
}
|
||||
return family
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package semconv
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMembersReturnsCurrentBeforeHistoricalName(t *testing.T) {
|
||||
selector := telemetrytypes.FieldKeySelector{
|
||||
Name: "deployment.environment",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
}
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"deployment.environment.name", "deployment.environment"},
|
||||
Members(KindAttribute, selector),
|
||||
"members should use current-first fallback order",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCurrentReturnsCanonicalName(t *testing.T) {
|
||||
selector := telemetrytypes.FieldKeySelector{
|
||||
Name: "deployment.environment",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
}
|
||||
|
||||
assert.Equal(t,
|
||||
"deployment.environment.name",
|
||||
Current(KindAttribute, selector),
|
||||
"historical name should resolve to the current family name",
|
||||
)
|
||||
}
|
||||
|
||||
func TestMembersReturnsInputWhenKindDoesNotMatch(t *testing.T) {
|
||||
selector := telemetrytypes.FieldKeySelector{
|
||||
Name: "deployment.environment",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
}
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"deployment.environment"},
|
||||
Members(KindMetric, selector),
|
||||
"an attribute family must not match a metric-name lookup",
|
||||
)
|
||||
}
|
||||
|
||||
func TestAllReturnsDefensiveCopies(t *testing.T) {
|
||||
first := All()
|
||||
require.NotEmpty(t, first, "generated families must not be empty")
|
||||
first[0].Old[0] = "mutated"
|
||||
|
||||
second := All()
|
||||
assert.NotEqual(t, "mutated", second[0].Old[0], "All must not expose mutable generated data")
|
||||
}
|
||||
@@ -234,7 +234,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
|
||||
sqlmigration.NewFillDashboardSpecCollectionsFactory(sqlstore, dashboardStore),
|
||||
sqlmigration.NewScrubEmailChannelTransportFactory(sqlstore),
|
||||
sqlmigration.NewAddDashboardTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addDashboardTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddDashboardTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_dashboard_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addDashboardTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addDashboardTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addDashboardTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id FROM organizations`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var orgID string
|
||||
if err := rows.Scan(&orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
orgIDs = append(orgIDs, orgID)
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
// The dashboard routes, including the public sharing config, now authorize per
|
||||
// resource, so every org needs the managed-role dashboard tuples. Orgs
|
||||
// bootstrapped before these entries landed in ManagedRoleToTransactions never
|
||||
// got them.
|
||||
tuples := []migrationTuple{
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "read"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "update"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "delete"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "create"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "list"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "read"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "update"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "delete"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "create"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "list"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "dashboard", "read"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "dashboard", "list"},
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addDashboardTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -44,73 +44,6 @@ func keyIndexFilter(key *telemetrytypes.TelemetryFieldKey) any {
|
||||
return fmt.Sprintf(`%%%s%%`, key.Name)
|
||||
}
|
||||
|
||||
func memberKey(key *telemetrytypes.TelemetryFieldKey, name string) *telemetrytypes.TelemetryFieldKey {
|
||||
member := *key
|
||||
member.Name = name
|
||||
return &member
|
||||
}
|
||||
|
||||
func keyIndexCondition(sb *sqlbuilder.SelectBuilder, column string, key *telemetrytypes.TelemetryFieldKey, members []string) string {
|
||||
conditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
conditions = append(conditions, sb.Like(column, keyIndexFilter(memberKey(key, member))))
|
||||
}
|
||||
if len(conditions) == 1 {
|
||||
return conditions[0]
|
||||
}
|
||||
return sb.Or(conditions...)
|
||||
}
|
||||
|
||||
func valueIndexCondition(
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
column string,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
members []string,
|
||||
op qbtypes.FilterOperator,
|
||||
value any,
|
||||
caseInsensitive bool,
|
||||
) string {
|
||||
conditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
patterns := valueForIndexFilter(op, memberKey(key, member), value)
|
||||
switch values := patterns.(type) {
|
||||
case []string:
|
||||
for _, pattern := range values {
|
||||
conditions = append(conditions, sb.Like(column, pattern))
|
||||
}
|
||||
default:
|
||||
if caseInsensitive {
|
||||
conditions = append(conditions, sb.ILike(column, values))
|
||||
} else {
|
||||
conditions = append(conditions, sb.Like(column, values))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(conditions) == 1 {
|
||||
return conditions[0]
|
||||
}
|
||||
return sb.Or(conditions...)
|
||||
}
|
||||
|
||||
func memberPresenceCondition(sb *sqlbuilder.SelectBuilder, column string, members []string, exists bool) string {
|
||||
conditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
field := fmt.Sprintf("simpleJSONHas(%s, '%s')", column, member)
|
||||
if exists {
|
||||
conditions = append(conditions, sb.E(field, true))
|
||||
} else {
|
||||
conditions = append(conditions, sb.NE(field, true))
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
if len(conditions) == 1 {
|
||||
return conditions[0]
|
||||
}
|
||||
return sb.Or(conditions...)
|
||||
}
|
||||
return sb.And(conditions...)
|
||||
}
|
||||
|
||||
// SkipResourceFilter is not applicable here: the fingerprint table only stores resource attributes.
|
||||
func (b *defaultConditionBuilder) ConditionFor(
|
||||
ctx context.Context,
|
||||
@@ -182,10 +115,8 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
// as we have not changed the resource column in the resource fingerprint table.
|
||||
column := columns[0]
|
||||
|
||||
members := resourceSemconvMembers(key)
|
||||
isFamily := len(members) > 1
|
||||
keyIdxFilter := keyIndexCondition(sb, column.Name, key, members)
|
||||
singleValueIndexFilter := valueForIndexFilter(op, memberKey(key, members[0]), value)
|
||||
keyIdxFilter := sb.Like(column.Name, keyIndexFilter(key))
|
||||
valueForIndexFilter := valueForIndexFilter(op, key, value)
|
||||
|
||||
fieldName, err := b.fm.FieldFor(ctx, valuer.UUID{}, startNs, endNs, key)
|
||||
if err != nil {
|
||||
@@ -197,15 +128,12 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
return sb.And(
|
||||
sb.E(fieldName, formattedValue),
|
||||
keyIdxFilter,
|
||||
valueIndexCondition(sb, column.Name, key, members, op, value, false),
|
||||
sb.Like(column.Name, valueForIndexFilter),
|
||||
), nil
|
||||
case qbtypes.FilterOperatorNotEqual:
|
||||
if isFamily {
|
||||
return sb.NE(fieldName, formattedValue), nil
|
||||
}
|
||||
return sb.And(
|
||||
sb.NE(fieldName, formattedValue),
|
||||
sb.NotLike(column.Name, singleValueIndexFilter),
|
||||
sb.NotLike(column.Name, valueForIndexFilter),
|
||||
), nil
|
||||
case qbtypes.FilterOperatorGreaterThan:
|
||||
return sb.And(sb.GT(fieldName, formattedValue), keyIdxFilter), nil
|
||||
@@ -220,7 +148,7 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
return sb.And(
|
||||
sb.ILike(fieldName, formattedValue),
|
||||
keyIdxFilter,
|
||||
valueIndexCondition(sb, column.Name, key, members, op, value, true),
|
||||
sb.ILike(column.Name, valueForIndexFilter),
|
||||
), nil
|
||||
case qbtypes.FilterOperatorNotLike, qbtypes.FilterOperatorNotILike:
|
||||
// no index filter: as cannot apply `not contains x%y` as y can be somewhere else
|
||||
@@ -257,11 +185,13 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
inConditions = append(inConditions, sb.E(fieldName, querybuilder.FormatValueForContains(v)))
|
||||
}
|
||||
mainCondition := sb.Or(inConditions...)
|
||||
mainCondition = sb.And(
|
||||
mainCondition,
|
||||
keyIdxFilter,
|
||||
valueIndexCondition(sb, column.Name, key, members, op, value, false),
|
||||
)
|
||||
valConditions := make([]string, 0, len(values))
|
||||
if valuesForIndexFilter, ok := valueForIndexFilter.([]string); ok {
|
||||
for _, v := range valuesForIndexFilter {
|
||||
valConditions = append(valConditions, sb.Like(column.Name, v))
|
||||
}
|
||||
}
|
||||
mainCondition = sb.And(mainCondition, keyIdxFilter, sb.Or(valConditions...))
|
||||
|
||||
return mainCondition, nil
|
||||
case qbtypes.FilterOperatorNotIn:
|
||||
@@ -274,11 +204,8 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
notInConditions = append(notInConditions, sb.NE(fieldName, querybuilder.FormatValueForContains(v)))
|
||||
}
|
||||
mainCondition := sb.And(notInConditions...)
|
||||
if isFamily {
|
||||
return mainCondition, nil
|
||||
}
|
||||
valConditions := make([]string, 0, len(values))
|
||||
if valuesForIndexFilter, ok := singleValueIndexFilter.([]string); ok {
|
||||
if valuesForIndexFilter, ok := valueForIndexFilter.([]string); ok {
|
||||
for _, v := range valuesForIndexFilter {
|
||||
valConditions = append(valConditions, sb.NotLike(column.Name, v))
|
||||
}
|
||||
@@ -288,11 +215,13 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
|
||||
case qbtypes.FilterOperatorExists:
|
||||
return sb.And(
|
||||
memberPresenceCondition(sb, column.Name, members, true),
|
||||
sb.E(fmt.Sprintf("simpleJSONHas(%s, '%s')", column.Name, key.Name), true),
|
||||
keyIdxFilter,
|
||||
), nil
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
return memberPresenceCondition(sb, column.Name, members, false), nil
|
||||
return sb.And(
|
||||
sb.NE(fmt.Sprintf("simpleJSONHas(%s, '%s')", column.Name, key.Name), true),
|
||||
), nil
|
||||
|
||||
case qbtypes.FilterOperatorRegexp:
|
||||
return sb.And(
|
||||
@@ -308,7 +237,7 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
return sb.And(
|
||||
sb.ILike(fieldName, fmt.Sprintf(`%%%s%%`, formattedValue)),
|
||||
keyIdxFilter,
|
||||
valueIndexCondition(sb, column.Name, key, members, op, value, true),
|
||||
sb.ILike(column.Name, valueForIndexFilter),
|
||||
), nil
|
||||
case qbtypes.FilterOperatorNotContains:
|
||||
// no index filter: as cannot apply `not contains x%y` as y can be somewhere else
|
||||
|
||||
@@ -198,75 +198,6 @@ func TestConditionBuilder(t *testing.T) {
|
||||
expected: "match(simpleJSONExtractString(labels, 'k8s.namespace.name'), ?) AND labels LIKE ?",
|
||||
expectedArgs: []any{"ban.*", "%k8s.namespace.name%"},
|
||||
},
|
||||
{
|
||||
name: "semantic convention family equality uses current-first fallback",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
},
|
||||
op: qbtypes.FilterOperatorEqual,
|
||||
value: "production",
|
||||
expected: "COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), '')) = ? AND (labels LIKE ? OR labels LIKE ?) AND (labels LIKE ? OR labels LIKE ?)",
|
||||
expectedArgs: []any{
|
||||
"production",
|
||||
"%deployment.environment.name%",
|
||||
"%deployment.environment%",
|
||||
`%deployment.environment.name":"production%`,
|
||||
`%deployment.environment":"production%`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "old semantic convention request uses only current metadata member",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name"},
|
||||
},
|
||||
op: qbtypes.FilterOperatorEqual,
|
||||
value: "production",
|
||||
expected: "simpleJSONExtractString(labels, 'deployment.environment.name') = ? AND labels LIKE ? AND labels LIKE ?",
|
||||
expectedArgs: []any{"production", "%deployment.environment.name%", `%deployment.environment.name":"production%`},
|
||||
},
|
||||
{
|
||||
name: "semantic convention family negative filter does not reject fallback rows",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
},
|
||||
op: qbtypes.FilterOperatorNotEqual,
|
||||
value: "staging",
|
||||
expected: "COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), '')) <> ?",
|
||||
expectedArgs: []any{"staging"},
|
||||
},
|
||||
{
|
||||
name: "semantic convention family exists checks every member",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
},
|
||||
op: qbtypes.FilterOperatorExists,
|
||||
expected: "(simpleJSONHas(labels, 'deployment.environment.name') = ? OR simpleJSONHas(labels, 'deployment.environment') = ?) AND (labels LIKE ? OR labels LIKE ?)",
|
||||
expectedArgs: []any{true, true, "%deployment.environment.name%", "%deployment.environment%"},
|
||||
},
|
||||
{
|
||||
name: "semantic convention family not exists checks every member",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
},
|
||||
op: qbtypes.FilterOperatorNotExists,
|
||||
expected: "(simpleJSONHas(labels, 'deployment.environment.name') <> ? AND simpleJSONHas(labels, 'deployment.environment') <> ?)",
|
||||
expectedArgs: []any{true, true},
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
|
||||
@@ -3,10 +3,8 @@ package resourcefilter
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -34,20 +32,6 @@ func NewFieldMapper() *defaultFieldMapper {
|
||||
return &defaultFieldMapper{}
|
||||
}
|
||||
|
||||
func resourceSemconvMembers(key *telemetrytypes.TelemetryFieldKey) []string {
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return []string{key.Name}
|
||||
}
|
||||
if len(key.SemconvMembers) > 0 {
|
||||
return key.SemconvMembers
|
||||
}
|
||||
return semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: key.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *defaultFieldMapper) getColumn(
|
||||
_ context.Context,
|
||||
_, _ uint64,
|
||||
@@ -82,15 +66,7 @@ func (m *defaultFieldMapper) FieldFor(
|
||||
return "", err
|
||||
}
|
||||
if key.FieldContext == telemetrytypes.FieldContextResource {
|
||||
members := resourceSemconvMembers(key)
|
||||
if len(members) > 1 {
|
||||
values := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
values = append(values, fmt.Sprintf("NULLIF(simpleJSONExtractString(%s, '%s'), '')", columns[0].Name, member))
|
||||
}
|
||||
return "COALESCE(" + strings.Join(values, ", ") + ")", nil
|
||||
}
|
||||
return fmt.Sprintf("simpleJSONExtractString(%s, '%s')", columns[0].Name, members[0]), nil
|
||||
return fmt.Sprintf("simpleJSONExtractString(%s, '%s')", columns[0].Name, key.Name), nil
|
||||
}
|
||||
return columns[0].Name, nil
|
||||
}
|
||||
|
||||
35
pkg/telemetrymetadata/backward_compat_keys.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package telemetrymetadata
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
|
||||
type BackwardCompatibleKeyMap map[string]string
|
||||
|
||||
var (
|
||||
TracesBackwardCompatKeys = BackwardCompatibleKeyMap{
|
||||
"net.peer.name": "server.address",
|
||||
"server.address": "net.peer.name",
|
||||
"http.url": "url.full",
|
||||
"url.full": "http.url",
|
||||
}
|
||||
|
||||
// LogsBackwardCompatKeys contains bidirectional mappings for logs.
|
||||
// Currently empty, can be extended in the future.
|
||||
LogsBackwardCompatKeys = BackwardCompatibleKeyMap{}
|
||||
|
||||
// MetricsBackwardCompatKeys contains bidirectional mappings for metrics.
|
||||
// Currently empty, can be extended in the future.
|
||||
MetricsBackwardCompatKeys = BackwardCompatibleKeyMap{}
|
||||
)
|
||||
|
||||
func GetBackwardCompatKeysForSignal(signal telemetrytypes.Signal) BackwardCompatibleKeyMap {
|
||||
switch signal {
|
||||
case telemetrytypes.SignalTraces:
|
||||
return TracesBackwardCompatKeys
|
||||
case telemetrytypes.SignalLogs:
|
||||
return LogsBackwardCompatKeys
|
||||
case telemetrytypes.SignalMetrics:
|
||||
return MetricsBackwardCompatKeys
|
||||
default:
|
||||
return BackwardCompatibleKeyMap{}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,7 +14,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/audittelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metertelemetryschema"
|
||||
@@ -153,102 +151,6 @@ func (t *telemetryMetaStore) tracesTblStatementToFieldKeys(ctx context.Context)
|
||||
return materialisedKeys, nil
|
||||
}
|
||||
|
||||
func traceSemconvMembers(name string, fieldContext telemetrytypes.FieldContext) []string {
|
||||
return semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: fieldContext,
|
||||
})
|
||||
}
|
||||
|
||||
func traceSemconvDuplicateFactor() int {
|
||||
factor := 1
|
||||
for _, family := range semconv.All() {
|
||||
if family.Kind != semconv.KindAttribute {
|
||||
continue
|
||||
}
|
||||
if _, ok := semconv.Lookup(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: family.Current,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
}); ok {
|
||||
factor = max(factor, len(family.Old)+1)
|
||||
}
|
||||
}
|
||||
return factor
|
||||
}
|
||||
|
||||
// canonicalizeTraceSemconvKeys presents one current-name key for each family.
|
||||
// If metadata contains both spellings, metadata attached to the current name
|
||||
// wins; otherwise the old entry is copied under the current response name.
|
||||
func canonicalizeTraceSemconvKeys(keys []*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
|
||||
result := make([]*telemetrytypes.TelemetryFieldKey, 0, len(keys))
|
||||
indexByIdentity := make(map[string]int)
|
||||
currentSourceByIdentity := make(map[string]bool)
|
||||
|
||||
for _, key := range keys {
|
||||
if key.Signal != telemetrytypes.SignalTraces {
|
||||
result = append(result, key)
|
||||
continue
|
||||
}
|
||||
|
||||
family, ok := semconv.Lookup(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: key.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: key.FieldContext,
|
||||
})
|
||||
if !ok {
|
||||
result = append(result, key)
|
||||
continue
|
||||
}
|
||||
|
||||
resolved := *key
|
||||
resolved.Name = family.Current
|
||||
resolved.SemconvMembers = []string{key.Name}
|
||||
identity := resolved.Name + ";" + resolved.Signal.StringValue() + ";" + resolved.FieldContext.StringValue() + ";" + resolved.FieldDataType.StringValue()
|
||||
fromCurrent := key.Name == family.Current
|
||||
|
||||
if index, found := indexByIdentity[identity]; found {
|
||||
physicalMembers := result[index].SemconvMembers
|
||||
if fromCurrent && !currentSourceByIdentity[identity] {
|
||||
result[index] = &resolved
|
||||
currentSourceByIdentity[identity] = true
|
||||
}
|
||||
for _, member := range physicalMembers {
|
||||
if !slices.Contains(result[index].SemconvMembers, member) {
|
||||
result[index].SemconvMembers = append(result[index].SemconvMembers, member)
|
||||
}
|
||||
}
|
||||
if !slices.Contains(result[index].SemconvMembers, key.Name) {
|
||||
result[index].SemconvMembers = append(result[index].SemconvMembers, key.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
indexByIdentity[identity] = len(result)
|
||||
currentSourceByIdentity[identity] = fromCurrent
|
||||
result = append(result, &resolved)
|
||||
}
|
||||
|
||||
for _, key := range result {
|
||||
if len(key.SemconvMembers) < 2 {
|
||||
continue
|
||||
}
|
||||
present := make(map[string]bool, len(key.SemconvMembers))
|
||||
for _, member := range key.SemconvMembers {
|
||||
present[member] = true
|
||||
}
|
||||
ordered := make([]string, 0, len(key.SemconvMembers))
|
||||
for _, member := range traceSemconvMembers(key.Name, key.FieldContext) {
|
||||
if present[member] {
|
||||
ordered = append(ordered, member)
|
||||
}
|
||||
}
|
||||
key.SemconvMembers = ordered
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// getTracesKeys returns the keys from the spans that match the field selection criteria.
|
||||
func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelectors []*telemetrytypes.FieldKeySelector) ([]*telemetrytypes.TelemetryFieldKey, bool, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
@@ -300,23 +202,10 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
|
||||
|
||||
// key part of the selector
|
||||
fieldKeyConds := []string{}
|
||||
members := traceSemconvMembers(fieldKeySelector.Name, fieldKeySelector.FieldContext)
|
||||
if fieldKeySelector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
|
||||
if len(members) == 1 {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.E("tagKey", members[0]))
|
||||
} else {
|
||||
memberValues := make([]any, 0, len(members))
|
||||
for _, member := range members {
|
||||
memberValues = append(memberValues, member)
|
||||
}
|
||||
fieldKeyConds = append(fieldKeyConds, sb.In("tagKey", memberValues...))
|
||||
}
|
||||
fieldKeyConds = append(fieldKeyConds, sb.E("tagKey", fieldKeySelector.Name))
|
||||
} else {
|
||||
memberConditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
memberConditions = append(memberConditions, sb.ILike("tagKey", "%"+escapeForLike(member)+"%"))
|
||||
}
|
||||
fieldKeyConds = append(fieldKeyConds, sb.Or(memberConditions...))
|
||||
fieldKeyConds = append(fieldKeyConds, sb.ILike("tagKey", "%"+escapeForLike(fieldKeySelector.Name)+"%"))
|
||||
}
|
||||
|
||||
searchTexts = append(searchTexts, fieldKeySelector.Name)
|
||||
@@ -349,10 +238,8 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
|
||||
mainSb.From(mainSb.BuilderAs(sb, "sub_query"))
|
||||
mainSb.GroupBy("tag_key", "tag_type", "tag_data_type")
|
||||
mainSb.OrderBy("priority")
|
||||
// Family members collapse after the database query. In the worst case each
|
||||
// logical key occupies one row per family member, so fetch enough physical
|
||||
// rows to return the requested logical page, plus one to detect truncation.
|
||||
mainSb.Limit(limit*traceSemconvDuplicateFactor() + 1)
|
||||
// query one extra to check if we hit the limit
|
||||
mainSb.Limit(limit + 1)
|
||||
|
||||
query, args := mainSb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
@@ -362,7 +249,14 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
|
||||
}
|
||||
defer rows.Close()
|
||||
keys := []*telemetrytypes.TelemetryFieldKey{}
|
||||
rowCount := 0
|
||||
for rows.Next() {
|
||||
rowCount++
|
||||
// reached the limit, we know there are more results
|
||||
if rowCount > limit {
|
||||
break
|
||||
}
|
||||
|
||||
var name string
|
||||
var fieldContext telemetrytypes.FieldContext
|
||||
var fieldDataType telemetrytypes.FieldDataType
|
||||
@@ -391,11 +285,8 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
|
||||
return nil, false, errors.Wrap(rows.Err(), errors.TypeInternal, errors.CodeInternal, ErrFailedToGetTracesKeys.Error())
|
||||
}
|
||||
|
||||
keys = canonicalizeTraceSemconvKeys(keys)
|
||||
complete := len(keys) <= limit
|
||||
if !complete {
|
||||
keys = keys[:limit]
|
||||
}
|
||||
// hit the limit? (only counting DB results)
|
||||
complete := rowCount <= limit
|
||||
|
||||
staticKeys := []string{"isRoot", "isEntryPoint"}
|
||||
staticKeys = append(staticKeys, maps.Keys(tracestelemetryschema.IntrinsicFields)...)
|
||||
@@ -1217,6 +1108,40 @@ func (t *telemetryMetaStore) getMeterSourceMetricKeys(ctx context.Context, field
|
||||
|
||||
}
|
||||
|
||||
// applyBackwardCompatibleKeys adds backward compatible key aliases to the map.
|
||||
func applyBackwardCompatibleKeys(mapOfKeys map[string][]*telemetrytypes.TelemetryFieldKey) {
|
||||
// Get backward compatible keys for all signals
|
||||
backwardCompatKeysBySignal := map[telemetrytypes.Signal]BackwardCompatibleKeyMap{
|
||||
telemetrytypes.SignalTraces: GetBackwardCompatKeysForSignal(telemetrytypes.SignalTraces),
|
||||
telemetrytypes.SignalLogs: GetBackwardCompatKeysForSignal(telemetrytypes.SignalLogs),
|
||||
telemetrytypes.SignalMetrics: GetBackwardCompatKeysForSignal(telemetrytypes.SignalMetrics),
|
||||
}
|
||||
|
||||
// Iterate over existing keys and add aliases if they exist in backward compat mapping
|
||||
for srcKey, srcKeys := range mapOfKeys {
|
||||
for _, srcKeyEntry := range srcKeys {
|
||||
backwardCompatKeys := backwardCompatKeysBySignal[srcKeyEntry.Signal]
|
||||
if backwardCompatKeys == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if aliasKey, ok := backwardCompatKeys[srcKey]; ok {
|
||||
if _, aliasExists := mapOfKeys[aliasKey]; !aliasExists {
|
||||
aliasKeyEntry := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: aliasKey,
|
||||
Signal: srcKeyEntry.Signal,
|
||||
FieldContext: srcKeyEntry.FieldContext,
|
||||
FieldDataType: srcKeyEntry.FieldDataType,
|
||||
}
|
||||
mapOfKeys[aliasKey] = []*telemetrytypes.TelemetryFieldKey{aliasKeyEntry}
|
||||
}
|
||||
// Found the alias for this signal, no need to check other entries
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enrichWithIntrinsicMetricKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, selectors []*telemetrytypes.FieldKeySelector) map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
if len(selectors) == 0 {
|
||||
return keys
|
||||
@@ -1347,6 +1272,7 @@ func (t *telemetryMetaStore) GetKeys(ctx context.Context, orgID valuer.UUID, fie
|
||||
mapOfKeys[key.Name] = append(mapOfKeys[key.Name], key)
|
||||
}
|
||||
|
||||
applyBackwardCompatibleKeys(mapOfKeys)
|
||||
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, selectors)
|
||||
if t.fl.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, selectors)
|
||||
@@ -1427,6 +1353,7 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, orgID valuer.UUID
|
||||
mapOfKeys[key.Name] = append(mapOfKeys[key.Name], key)
|
||||
}
|
||||
|
||||
applyBackwardCompatibleKeys(mapOfKeys)
|
||||
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, fieldKeySelectors)
|
||||
if t.fl.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, fieldKeySelectors)
|
||||
@@ -1440,18 +1367,7 @@ func (t *telemetryMetaStore) GetKey(ctx context.Context, orgID valuer.UUID, fiel
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members := semconv.Members(semconv.KindAttribute, *fieldKeySelector)
|
||||
resolved := make([]*telemetrytypes.TelemetryFieldKey, 0)
|
||||
seen := make(map[*telemetrytypes.TelemetryFieldKey]bool)
|
||||
for _, member := range members {
|
||||
for _, key := range keys[member] {
|
||||
if !seen[key] {
|
||||
resolved = append(resolved, key)
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolved, nil
|
||||
return keys[fieldKeySelector.Name], nil
|
||||
}
|
||||
|
||||
func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, orgID valuer.UUID, fieldValueSelector *telemetrytypes.FieldValueSelector) ([]string, bool, error) {
|
||||
@@ -1626,16 +1542,7 @@ func (t *telemetryMetaStore) getSpanFieldValues(ctx context.Context, fieldValueS
|
||||
sb := sqlbuilder.Select("DISTINCT string_value, number_value").From(t.tracesDBName + "." + t.tracesFieldsTblName)
|
||||
|
||||
if fieldValueSelector.Name != "" {
|
||||
members := traceSemconvMembers(fieldValueSelector.Name, fieldValueSelector.FieldContext)
|
||||
if len(members) == 1 {
|
||||
sb.Where(sb.E("tag_key", members[0]))
|
||||
} else {
|
||||
memberValues := make([]any, 0, len(members))
|
||||
for _, member := range members {
|
||||
memberValues = append(memberValues, member)
|
||||
}
|
||||
sb.Where(sb.In("tag_key", memberValues...))
|
||||
}
|
||||
sb.Where(sb.E("tag_key", fieldValueSelector.Name))
|
||||
}
|
||||
|
||||
// now look at the field context
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -84,83 +83,3 @@ func TestGetFirstSeenFromMetricMetadata(t *testing.T) {
|
||||
t.Errorf("there were unfulfilled expectations: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeTraceSemconvKeys(t *testing.T) {
|
||||
oldResource := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
Description: "old resource metadata",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
currentResource := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Description: "current resource metadata",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
oldAttribute := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
oldLogResource := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
|
||||
result := canonicalizeTraceSemconvKeys([]*telemetrytypes.TelemetryFieldKey{
|
||||
oldResource,
|
||||
currentResource,
|
||||
oldAttribute,
|
||||
oldLogResource,
|
||||
})
|
||||
|
||||
require.Len(t, result, 3)
|
||||
assert.Equal(t, "deployment.environment.name", result[0].Name)
|
||||
assert.Equal(t, "current resource metadata", result[0].Description)
|
||||
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, result[0].SemconvMembers)
|
||||
assert.Equal(t, "deployment.environment.name", result[1].Name)
|
||||
assert.Equal(t, telemetrytypes.FieldContextAttribute, result[1].FieldContext)
|
||||
assert.Equal(t, []string{"deployment.environment"}, result[1].SemconvMembers)
|
||||
assert.Equal(t, "deployment.environment", result[2].Name, "phase 1 must not rewrite raw log metadata")
|
||||
}
|
||||
|
||||
func TestGetSpanFieldValuesMergesSemconvFamily(t *testing.T) {
|
||||
mockTelemetryStore := telemetrystoretest.New(telemetrystore.Config{}, ®exMatcher{})
|
||||
mock := mockTelemetryStore.Mock()
|
||||
|
||||
metadata := NewTelemetryMetaStore(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockTelemetryStore,
|
||||
flaggertest.New(t),
|
||||
)
|
||||
|
||||
mock.ExpectQuery(`SELECT DISTINCT string_value, number_value FROM signoz_traces\.distributed_tag_attributes_v2 WHERE tag_key IN \(\?, \?\) AND tag_type = \? AND tag_data_type = \? LIMIT \?`).
|
||||
WithArgs("deployment.environment.name", "deployment.environment", "resource", "string", 51).
|
||||
WillReturnRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "string_value", Type: "String"},
|
||||
{Name: "number_value", Type: "Float64"},
|
||||
}, [][]any{
|
||||
{"production", float64(0)},
|
||||
{"staging", float64(0)},
|
||||
{"production", float64(0)},
|
||||
}))
|
||||
|
||||
values, complete, err := metadata.GetAllValues(context.Background(), valuer.UUID{}, &telemetrytypes.FieldValueSelector{
|
||||
FieldKeySelector: &telemetrytypes.FieldKeySelector{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Name: "deployment.environment",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, complete)
|
||||
assert.Equal(t, []string{"production", "staging"}, values.StringValues)
|
||||
assert.NoError(t, mock.ExpectationsWereMet(), "all expected metadata queries should be executed")
|
||||
}
|
||||
|
||||
@@ -154,15 +154,6 @@ func (c *conditionBuilder) conditionFor(
|
||||
// in the query builder, `exists` and `not exists` are used for
|
||||
// key membership checks, so depending on the column type, the condition changes
|
||||
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
|
||||
// A semantic-convention family is represented by one current-first value
|
||||
// expression, but presence still has to inspect every physical member. In
|
||||
// particular, using ExistsExpression below with the requested key would add
|
||||
// a mapContains check for only that spelling and reject fallback-only rows.
|
||||
if isTraceSemconvFamily(key) {
|
||||
if fm, ok := c.fm.(*fieldMapper); ok {
|
||||
return fm.existsExpressionFor(ctx, orgID, startNs, endNs, key, operator == qbtypes.FilterOperatorExists)
|
||||
}
|
||||
}
|
||||
columns, err := c.fm.ColumnFor(ctx, orgID, startNs, endNs, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -210,32 +210,6 @@ func TestConditionFor(t *testing.T) {
|
||||
expectedSQL: "NOT mapContains(attributes_string, 'user.id')",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Equal operator - semantic convention family",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "production",
|
||||
expectedSQL: "(COALESCE(NULLIF(attributes_string['deployment.environment.name'], ''), NULLIF(attributes_string['deployment.environment'], '')) = ? AND ((mapContains(attributes_string, 'deployment.environment.name') OR mapContains(attributes_string, 'deployment.environment'))))",
|
||||
expectedArgs: []any{"production"},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Not Exists operator - semantic convention family",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotExists,
|
||||
expectedSQL: "NOT (((mapContains(attributes_string, 'deployment.environment.name') OR mapContains(attributes_string, 'deployment.environment'))))",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Exists operator - json field",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -168,32 +167,6 @@ func NewFieldMapper() *fieldMapper {
|
||||
return &fieldMapper{}
|
||||
}
|
||||
|
||||
func traceSemconvMembers(key *telemetrytypes.TelemetryFieldKey) []string {
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource && key.FieldContext != telemetrytypes.FieldContextAttribute {
|
||||
return []string{key.Name}
|
||||
}
|
||||
if len(key.SemconvMembers) > 0 {
|
||||
return key.SemconvMembers
|
||||
}
|
||||
return semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: key.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: key.FieldContext,
|
||||
})
|
||||
}
|
||||
|
||||
func isTraceSemconvFamily(key *telemetrytypes.TelemetryFieldKey) bool {
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource && key.FieldContext != telemetrytypes.FieldContextAttribute {
|
||||
return false
|
||||
}
|
||||
_, ok := semconv.Lookup(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: key.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: key.FieldContext,
|
||||
})
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m *fieldMapper) getColumn(
|
||||
_ context.Context,
|
||||
_, _ uint64,
|
||||
@@ -318,25 +291,10 @@ func (m *fieldMapper) resolveColumnExprs(
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
|
||||
}
|
||||
members := traceSemconvMembers(key)
|
||||
if len(members) > 1 {
|
||||
values := make([]string, 0, len(members))
|
||||
guards := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
// The String cast is required because ClickHouse does not allow
|
||||
// Variant/Dynamic values in GROUP BY.
|
||||
value := fmt.Sprintf("%s.`%s`::String", columnName, member)
|
||||
values = append(values, fmt.Sprintf("NULLIF(%s, '')", value))
|
||||
guards = append(guards, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, member))
|
||||
}
|
||||
exprs = append(exprs, "COALESCE("+strings.Join(values, ", ")+")")
|
||||
existExprs = append(existExprs, "("+strings.Join(guards, " OR ")+")")
|
||||
} else {
|
||||
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
|
||||
// once ClickHouse is updated, check whether this cast can be removed.
|
||||
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, members[0]))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, members[0]))
|
||||
}
|
||||
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
|
||||
// once clickHouse dependency is updated, we need to check if we can remove it.
|
||||
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
|
||||
case schema.ColumnTypeEnumString,
|
||||
schema.ColumnTypeEnumUInt64,
|
||||
schema.ColumnTypeEnumUInt32,
|
||||
@@ -361,35 +319,13 @@ func (m *fieldMapper) resolveColumnExprs(
|
||||
|
||||
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
|
||||
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumFloat64, schema.ColumnTypeEnumBool:
|
||||
members := traceSemconvMembers(key)
|
||||
if len(members) > 1 {
|
||||
guards := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
guards = append(guards, fmt.Sprintf("mapContains(%s, '%s')", columnName, member))
|
||||
}
|
||||
if valueType.GetType() == schema.ColumnTypeEnumString {
|
||||
values := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
values = append(values, fmt.Sprintf("NULLIF(%s['%s'], '')", columnName, member))
|
||||
}
|
||||
exprs = append(exprs, "COALESCE("+strings.Join(values, ", ")+")")
|
||||
} else {
|
||||
branches := make([]string, 0, len(members)*2)
|
||||
for i, member := range members {
|
||||
branches = append(branches, guards[i], fmt.Sprintf("%s['%s']", columnName, member))
|
||||
}
|
||||
exprs = append(exprs, "multiIf("+strings.Join(branches, ", ")+", NULL)")
|
||||
}
|
||||
existExprs = append(existExprs, "("+strings.Join(guards, " OR ")+")")
|
||||
} else if key.Materialized {
|
||||
// a key could have been materialized, if so return the materialized column name
|
||||
physicalKey := *key
|
||||
physicalKey.Name = members[0]
|
||||
exprs = append(exprs, telemetrytypes.FieldKeyToMaterializedColumnName(&physicalKey))
|
||||
existExprs = append(existExprs, telemetrytypes.FieldKeyToMaterializedColumnNameForExists(&physicalKey))
|
||||
// a key could have been materialized, if so return the materialized column name
|
||||
if key.Materialized {
|
||||
exprs = append(exprs, telemetrytypes.FieldKeyToMaterializedColumnName(key))
|
||||
existExprs = append(existExprs, telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key))
|
||||
} else {
|
||||
exprs = append(exprs, fmt.Sprintf("%s['%s']", columnName, members[0]))
|
||||
existExprs = append(existExprs, fmt.Sprintf("mapContains(%s, '%s')", columnName, members[0]))
|
||||
exprs = append(exprs, fmt.Sprintf("%s['%s']", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("mapContains(%s, '%s')", columnName, key.Name))
|
||||
}
|
||||
default:
|
||||
return nil, nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "value type %s is not supported for map column type %s", valueType, column.Type)
|
||||
@@ -593,25 +529,6 @@ func (m *fieldMapper) existsExpressionFor(
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
exists bool,
|
||||
) (string, error) {
|
||||
if isTraceSemconvFamily(key) {
|
||||
_, existExprs, _, err := m.resolveColumnExprs(ctx, tsStart, tsEnd, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(existExprs) == 0 {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "no existence expression found for field %s", key.Name)
|
||||
}
|
||||
parts := make([]string, 0, len(existExprs))
|
||||
for _, expression := range existExprs {
|
||||
parts = append(parts, "("+expression+")")
|
||||
}
|
||||
combined := strings.Join(parts, " OR ")
|
||||
if exists {
|
||||
return combined, nil
|
||||
}
|
||||
return "NOT (" + combined + ")", nil
|
||||
}
|
||||
|
||||
columns, err := m.getColumn(ctx, tsStart, tsEnd, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
Materialized: true,
|
||||
Evolutions: mockEvolution,
|
||||
},
|
||||
expectedResult: "multiIf((resource.`deployment.environment.name` IS NOT NULL OR resource.`deployment.environment` IS NOT NULL), COALESCE(NULLIF(resource.`deployment.environment.name`::String, ''), NULLIF(resource.`deployment.environment`::String, '')), (mapContains(resources_string, 'deployment.environment.name') OR mapContains(resources_string, 'deployment.environment')), COALESCE(NULLIF(resources_string['deployment.environment.name'], ''), NULLIF(resources_string['deployment.environment'], '')), NULL)",
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`, `resource_string_deployment$$environment`, NULL)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
@@ -120,51 +120,6 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldForSemconvFamily(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fm := NewFieldMapper()
|
||||
start := uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
end := uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
|
||||
for _, requestedName := range []string{"deployment.environment.name", "deployment.environment"} {
|
||||
attributeKey := telemetrytypes.TelemetryFieldKey{
|
||||
Name: requestedName,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
attributeExpression, err := fm.FieldFor(ctx, valuer.UUID{}, start, end, &attributeKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"COALESCE(NULLIF(attributes_string['deployment.environment.name'], ''), NULLIF(attributes_string['deployment.environment'], ''))",
|
||||
attributeExpression,
|
||||
)
|
||||
|
||||
resourceKey := telemetrytypes.TelemetryFieldKey{
|
||||
Name: requestedName,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Materialized: true,
|
||||
Evolutions: MockEvolutionData(time.Date(2024, 6, 2, 0, 0, 0, 0, time.UTC)),
|
||||
}
|
||||
resourceExpression, err := fm.FieldFor(ctx, valuer.UUID{}, start, end, &resourceKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"multiIf((resource.`deployment.environment.name` IS NOT NULL OR resource.`deployment.environment` IS NOT NULL), COALESCE(NULLIF(resource.`deployment.environment.name`::String, ''), NULLIF(resource.`deployment.environment`::String, '')), (mapContains(resources_string, 'deployment.environment.name') OR mapContains(resources_string, 'deployment.environment')), COALESCE(NULLIF(resources_string['deployment.environment.name'], ''), NULLIF(resources_string['deployment.environment'], '')), NULL)",
|
||||
resourceExpression,
|
||||
)
|
||||
}
|
||||
|
||||
oldRequestWithCurrentOnlyMetadata := telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name"},
|
||||
}
|
||||
expression, err := fm.FieldFor(ctx, valuer.UUID{}, start, end, &oldRequestWithCurrentOnlyMetadata)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "attributes_string['deployment.environment.name']", expression)
|
||||
}
|
||||
|
||||
func TestFieldForResourceWithEvolution(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
releaseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
@@ -221,7 +176,7 @@ func TestFieldForResourceWithEvolution(t *testing.T) {
|
||||
},
|
||||
tsStart: uint64(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedResult: "COALESCE(NULLIF(resource.`deployment.environment.name`::String, ''), NULLIF(resource.`deployment.environment`::String, ''))",
|
||||
expectedResult: "resource.`deployment.environment`::String",
|
||||
},
|
||||
{
|
||||
name: "Window straddles release - materialized resource",
|
||||
@@ -234,7 +189,7 @@ func TestFieldForResourceWithEvolution(t *testing.T) {
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedResult: "multiIf((resource.`deployment.environment.name` IS NOT NULL OR resource.`deployment.environment` IS NOT NULL), COALESCE(NULLIF(resource.`deployment.environment.name`::String, ''), NULLIF(resource.`deployment.environment`::String, '')), (mapContains(resources_string, 'deployment.environment.name') OR mapContains(resources_string, 'deployment.environment')), COALESCE(NULLIF(resources_string['deployment.environment.name'], ''), NULLIF(resources_string['deployment.environment'], '')), NULL)",
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`, `resource_string_deployment$$environment`, NULL)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ var (
|
||||
ResourceMetaResourceCloudIntegration = NewResourceMetaResource(KindCloudIntegration)
|
||||
ResourceMetaResourceCloudIntegrationService = NewResourceMetaResource(KindCloudIntegrationService)
|
||||
ResourceMetaResourceIntegration = NewResourceMetaResource(KindIntegration)
|
||||
ResourceMetaResourceDashboard = NewResourceMetaResource(KindDashboard, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
|
||||
ResourceMetaResourceDashboard = NewResourceMetaResource(KindDashboard)
|
||||
ResourceMetaResourcePublicDashboard = NewResourceMetaResource(KindPublicDashboard)
|
||||
ResourceMetaResourceIngestionKey = NewResourceMetaResource(KindIngestionKey)
|
||||
ResourceMetaResourceIngestionLimit = NewResourceMetaResource(KindIngestionLimit)
|
||||
|
||||
@@ -47,8 +47,7 @@ type TelemetryFieldKey struct {
|
||||
Indexes []TelemetryFieldKeySkipIndex `json:"-"`
|
||||
Materialized bool `json:"-"` // refers to promoted in case of body.... fields
|
||||
|
||||
Evolutions []*EvolutionEntry `json:"-"`
|
||||
SemconvMembers []string `json:"-"`
|
||||
Evolutions []*EvolutionEntry `json:"-"`
|
||||
}
|
||||
|
||||
func (f *TelemetryFieldKey) KeyNameContainsArray() bool {
|
||||
@@ -129,7 +128,6 @@ func (f *TelemetryFieldKey) OverrideMetadataFrom(src *TelemetryFieldKey) {
|
||||
f.Materialized = src.Materialized
|
||||
f.JSONPlan = src.JSONPlan
|
||||
f.Evolutions = src.Evolutions
|
||||
f.SemconvMembers = src.SemconvMembers
|
||||
}
|
||||
|
||||
func (f *TelemetryFieldKey) Equal(key *TelemetryFieldKey) bool {
|
||||
|
||||
@@ -1,721 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
kindAttribute = "attribute"
|
||||
kindMetric = "metric"
|
||||
)
|
||||
|
||||
type stringListFlag []string
|
||||
|
||||
func (f *stringListFlag) String() string { return strings.Join(*f, ",") }
|
||||
func (f *stringListFlag) Set(value string) error {
|
||||
*f = append(*f, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type schemaFile struct {
|
||||
FileFormat string `yaml:"file_format"`
|
||||
SchemaURL string `yaml:"schema_url"`
|
||||
Versions map[string]schemaVersion `yaml:"versions"`
|
||||
}
|
||||
|
||||
type schemaVersion struct {
|
||||
All changeSection `yaml:"all"`
|
||||
Resources changeSection `yaml:"resources"`
|
||||
Spans changeSection `yaml:"spans"`
|
||||
Logs changeSection `yaml:"logs"`
|
||||
Metrics changeSection `yaml:"metrics"`
|
||||
}
|
||||
|
||||
type changeSection struct {
|
||||
Changes []schemaChange `yaml:"changes"`
|
||||
}
|
||||
|
||||
type schemaChange struct {
|
||||
RenameAttributes *attributeRename `yaml:"rename_attributes"`
|
||||
RenameMetrics map[string]string `yaml:"rename_metrics"`
|
||||
}
|
||||
|
||||
type attributeRename struct {
|
||||
AttributeMap map[string]string `yaml:"attribute_map"`
|
||||
ApplyToMetrics []string `yaml:"apply_to_metrics"`
|
||||
}
|
||||
|
||||
type overlayFile struct {
|
||||
DefaultEnabled bool `yaml:"default_enabled"`
|
||||
// Families is keyed only by current name. One name cannot carry separate
|
||||
// policies for attribute and metric families; set kind explicitly whenever
|
||||
// a metric-name family is configured.
|
||||
Families map[string]overlayFamily `yaml:"families"`
|
||||
}
|
||||
|
||||
type overlayFamily struct {
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
Kind string `yaml:"kind"`
|
||||
Old []string `yaml:"old"`
|
||||
AddOld []string `yaml:"add_old"`
|
||||
ExcludeOld []string `yaml:"exclude_old"`
|
||||
Contexts []string `yaml:"contexts"`
|
||||
Signals []string `yaml:"signals"`
|
||||
AddContexts []string `yaml:"add_contexts"`
|
||||
AddSignals []string `yaml:"add_signals"`
|
||||
ApplyToMetrics []string `yaml:"apply_to_metrics"`
|
||||
AddApplyToMetrics []string `yaml:"add_apply_to_metrics"`
|
||||
ValueMap map[string]string `yaml:"value_map"`
|
||||
}
|
||||
|
||||
type edge struct {
|
||||
old string
|
||||
current string
|
||||
kind string
|
||||
contexts []string
|
||||
signals []string
|
||||
allContexts bool
|
||||
allSignals bool
|
||||
applyToMetrics []string
|
||||
}
|
||||
|
||||
type graphKey struct{ kind, name string }
|
||||
|
||||
type generatedFamily struct {
|
||||
Current string
|
||||
Old []string
|
||||
Kind string
|
||||
Contexts []string
|
||||
Signals []string
|
||||
ApplyToMetrics []string
|
||||
ValueMap map[string]string
|
||||
}
|
||||
|
||||
func main() {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
var schemaPaths stringListFlag
|
||||
flag.Var(&schemaPaths, "schema", "schema source (repeatable)")
|
||||
overlayPath := flag.String("overlay", filepath.Join(root, "scripts/semconv/overlay.yaml"), "SigNoz overlay")
|
||||
goOutput := flag.String("go-out", filepath.Join(root, "pkg/semconv/families_gen.go"), "generated Go output")
|
||||
tsOutput := flag.String("ts-out", filepath.Join(root, "frontend/src/constants/generated/semconvFamilies.gen.ts"), "generated TypeScript output")
|
||||
check := flag.Bool("check", false, "fail if generated files are stale")
|
||||
flag.Parse()
|
||||
|
||||
if len(schemaPaths) == 0 {
|
||||
schemaPaths = append(schemaPaths, filepath.Join(root, "scripts/semconv/schema-1.42.0.yaml"))
|
||||
}
|
||||
|
||||
families, err := generate(schemaPaths, *overlayPath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
goBytes, err := renderGo(families)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
tsBytes := renderTypeScript(families)
|
||||
|
||||
if *check {
|
||||
if err := checkFile(*goOutput, goBytes); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if err := checkFile(*tsOutput, tsBytes); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(*goOutput, goBytes, 0o644); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(*tsOutput, tsBytes, 0o644); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func findRepoRoot() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", errors.New("could not find repository root")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func generate(schemaPaths []string, overlayPath string) ([]generatedFamily, error) {
|
||||
var schemas []schemaFile
|
||||
for _, path := range schemaPaths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read schema %s: %w", path, err)
|
||||
}
|
||||
var schema schemaFile
|
||||
if err := decodeKnownFields(data, &schema); err != nil {
|
||||
return nil, fmt.Errorf("parse schema %s: %w", path, err)
|
||||
}
|
||||
schemas = append(schemas, schema)
|
||||
}
|
||||
|
||||
overlayData, err := os.ReadFile(overlayPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read overlay: %w", err)
|
||||
}
|
||||
var overlay overlayFile
|
||||
if err := decodeKnownFields(overlayData, &overlay); err != nil {
|
||||
return nil, fmt.Errorf("parse overlay: %w", err)
|
||||
}
|
||||
|
||||
return buildFamilies(schemas, overlay)
|
||||
}
|
||||
|
||||
func decodeKnownFields(data []byte, target any) error {
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||
decoder.KnownFields(true)
|
||||
return decoder.Decode(target)
|
||||
}
|
||||
|
||||
func collectEdges(schemas []schemaFile) ([]edge, error) {
|
||||
var edges []edge
|
||||
for _, schema := range schemas {
|
||||
versions := make([]string, 0, len(schema.Versions))
|
||||
versionParts := make(map[string][3]int, len(schema.Versions))
|
||||
for version := range schema.Versions {
|
||||
parts, err := parseSchemaVersion(version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
versions = append(versions, version)
|
||||
versionParts[version] = parts
|
||||
}
|
||||
sort.Slice(versions, func(i, j int) bool {
|
||||
return compareVersionParts(versionParts[versions[i]], versionParts[versions[j]]) < 0
|
||||
})
|
||||
for _, versionName := range versions {
|
||||
version := schema.Versions[versionName]
|
||||
var versionEdges []edge
|
||||
sections := []struct {
|
||||
name string
|
||||
section changeSection
|
||||
}{
|
||||
{name: "all", section: version.All},
|
||||
{name: "resources", section: version.Resources},
|
||||
{name: "spans", section: version.Spans},
|
||||
{name: "logs", section: version.Logs},
|
||||
{name: "metrics", section: version.Metrics},
|
||||
}
|
||||
for _, scoped := range sections {
|
||||
contexts, signals, allContexts, allSignals, err := scopeForSection(scoped.name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, change := range scoped.section.Changes {
|
||||
if change.RenameAttributes != nil {
|
||||
for _, old := range sortedMapKeys(change.RenameAttributes.AttributeMap) {
|
||||
versionEdges = append(versionEdges, edge{
|
||||
old: old, current: change.RenameAttributes.AttributeMap[old], kind: kindAttribute,
|
||||
contexts: contexts, signals: signals,
|
||||
allContexts: allContexts, allSignals: allSignals,
|
||||
applyToMetrics: change.RenameAttributes.ApplyToMetrics,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, old := range sortedMapKeys(change.RenameMetrics) {
|
||||
versionEdges = append(versionEdges, edge{
|
||||
old: old, current: change.RenameMetrics[old], kind: kindMetric,
|
||||
contexts: []string{"metric"}, signals: []string{"metrics"},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rejectSameVersionChains(versionName, versionEdges); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edges = append(edges, versionEdges...)
|
||||
}
|
||||
}
|
||||
return edges, nil
|
||||
}
|
||||
|
||||
func rejectSameVersionChains(version string, edges []edge) error {
|
||||
oldNames := make(map[graphKey]struct{}, len(edges))
|
||||
for _, item := range edges {
|
||||
oldNames[graphKey{kind: item.kind, name: item.old}] = struct{}{}
|
||||
}
|
||||
for _, item := range edges {
|
||||
if _, ok := oldNames[graphKey{kind: item.kind, name: item.current}]; ok {
|
||||
return fmt.Errorf(
|
||||
"schema version %q contains a same-version %s rename chain through %q",
|
||||
version,
|
||||
item.kind,
|
||||
item.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSchemaVersion(version string) ([3]int, error) {
|
||||
parts := strings.Split(version, ".")
|
||||
if len(parts) != 3 {
|
||||
return [3]int{}, fmt.Errorf("schema version %q must contain major, minor, and patch numbers", version)
|
||||
}
|
||||
|
||||
var parsed [3]int
|
||||
for i, part := range parts {
|
||||
value, err := strconv.Atoi(part)
|
||||
if err != nil || value < 0 {
|
||||
return [3]int{}, fmt.Errorf("schema version %q contains invalid numeric component %q", version, part)
|
||||
}
|
||||
parsed[i] = value
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func compareVersionParts(left, right [3]int) int {
|
||||
for i := range left {
|
||||
if left[i] < right[i] {
|
||||
return -1
|
||||
}
|
||||
if left[i] > right[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func scopeForSection(section string) (contexts, signals []string, allContexts, allSignals bool, err error) {
|
||||
switch section {
|
||||
case "all":
|
||||
return nil, nil, true, true, nil
|
||||
case "resources":
|
||||
return []string{"resource"}, nil, false, true, nil
|
||||
case "spans":
|
||||
return []string{"attribute"}, []string{"traces"}, false, false, nil
|
||||
case "logs":
|
||||
return []string{"attribute"}, []string{"logs"}, false, false, nil
|
||||
case "metrics":
|
||||
return []string{"attribute"}, []string{"metrics"}, false, false, nil
|
||||
default:
|
||||
return nil, nil, false, false, fmt.Errorf("unsupported schema section %q", section)
|
||||
}
|
||||
}
|
||||
|
||||
func buildFamilies(schemas []schemaFile, overlay overlayFile) ([]generatedFamily, error) {
|
||||
edges, err := collectEdges(schemas)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := make(map[graphKey]string)
|
||||
for _, item := range edges {
|
||||
key := graphKey{kind: item.kind, name: item.old}
|
||||
if existing, ok := next[key]; ok && existing == item.current {
|
||||
// Repeated entries are common in chained schema histories. Treat an
|
||||
// identical edge as a no-op so it cannot sever a later edge in the
|
||||
// same chain (A -> B, B -> C, then a repeated A -> B).
|
||||
continue
|
||||
}
|
||||
// Schema history occasionally repeats an old name with a newer direct
|
||||
// destination or rolls a rename back. Edges are collected
|
||||
// oldest-to-newest, so the latest published current name must be a root.
|
||||
delete(next, graphKey{kind: item.kind, name: item.current})
|
||||
next[key] = item.current
|
||||
}
|
||||
|
||||
type familyState struct {
|
||||
family generatedFamily
|
||||
distance map[string]int
|
||||
allContexts bool
|
||||
allSignals bool
|
||||
}
|
||||
states := map[graphKey]*familyState{}
|
||||
for _, item := range edges {
|
||||
root, distance, err := rootFor(next, item.kind, item.old)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := graphKey{kind: item.kind, name: root}
|
||||
state := states[key]
|
||||
if state == nil {
|
||||
state = &familyState{
|
||||
family: generatedFamily{Current: root, Kind: item.kind},
|
||||
distance: map[string]int{},
|
||||
}
|
||||
states[key] = state
|
||||
}
|
||||
if prior, ok := state.distance[item.old]; !ok || distance < prior {
|
||||
state.distance[item.old] = distance
|
||||
}
|
||||
state.allContexts = state.allContexts || item.allContexts
|
||||
state.allSignals = state.allSignals || item.allSignals
|
||||
state.family.Contexts = appendUnique(state.family.Contexts, item.contexts...)
|
||||
state.family.Signals = appendUnique(state.family.Signals, item.signals...)
|
||||
state.family.ApplyToMetrics = appendUnique(state.family.ApplyToMetrics, item.applyToMetrics...)
|
||||
}
|
||||
|
||||
for _, state := range states {
|
||||
for old := range state.distance {
|
||||
if old != state.family.Current {
|
||||
state.family.Old = append(state.family.Old, old)
|
||||
}
|
||||
}
|
||||
sort.Slice(state.family.Old, func(i, j int) bool {
|
||||
left, right := state.family.Old[i], state.family.Old[j]
|
||||
if state.distance[left] != state.distance[right] {
|
||||
return state.distance[left] < state.distance[right]
|
||||
}
|
||||
return left < right
|
||||
})
|
||||
if state.allContexts {
|
||||
state.family.Contexts = nil
|
||||
} else {
|
||||
sort.Strings(state.family.Contexts)
|
||||
}
|
||||
if state.allSignals {
|
||||
state.family.Signals = nil
|
||||
} else {
|
||||
sort.Strings(state.family.Signals)
|
||||
}
|
||||
sort.Strings(state.family.ApplyToMetrics)
|
||||
}
|
||||
|
||||
for _, current := range sortedMapKeys(overlay.Families) {
|
||||
policy := overlay.Families[current]
|
||||
kind, err := normalizedOverlayKind(current, policy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
policy.Kind = kind
|
||||
overlay.Families[current] = policy
|
||||
key := graphKey{kind: kind, name: current}
|
||||
state := states[key]
|
||||
if state == nil {
|
||||
if len(policy.Old) == 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"overlay family %q with kind %q is absent from schemas and has no old members",
|
||||
current,
|
||||
kind,
|
||||
)
|
||||
}
|
||||
state = &familyState{
|
||||
family: generatedFamily{Current: current, Kind: kind, Old: append([]string(nil), policy.Old...)},
|
||||
distance: map[string]int{},
|
||||
}
|
||||
states[key] = state
|
||||
}
|
||||
applyOverlay(&state.family, policy)
|
||||
}
|
||||
|
||||
var result []generatedFamily
|
||||
for key, state := range states {
|
||||
policy, hasPolicy := overlay.Families[key.name]
|
||||
enabled := overlay.DefaultEnabled
|
||||
if hasPolicy && policy.Kind != key.kind {
|
||||
hasPolicy = false
|
||||
}
|
||||
if hasPolicy && policy.Enabled != nil {
|
||||
enabled = *policy.Enabled
|
||||
}
|
||||
if !enabled {
|
||||
continue
|
||||
}
|
||||
if len(state.family.Old) == 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"enabled family %q with kind %q has no old members",
|
||||
state.family.Current,
|
||||
state.family.Kind,
|
||||
)
|
||||
}
|
||||
sort.Strings(state.family.Contexts)
|
||||
sort.Strings(state.family.Signals)
|
||||
sort.Strings(state.family.ApplyToMetrics)
|
||||
result = append(result, state.family)
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Current != result[j].Current {
|
||||
return result[i].Current < result[j].Current
|
||||
}
|
||||
return result[i].Kind < result[j].Kind
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func rootFor(next map[graphKey]string, kind, name string) (string, int, error) {
|
||||
seen := map[string]bool{}
|
||||
distance := 0
|
||||
for {
|
||||
if seen[name] {
|
||||
return "", 0, fmt.Errorf("rename cycle for %s %q", kind, name)
|
||||
}
|
||||
seen[name] = true
|
||||
current, ok := next[graphKey{kind: kind, name: name}]
|
||||
if !ok {
|
||||
return name, distance, nil
|
||||
}
|
||||
name = current
|
||||
distance++
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedOverlayKind(current string, policy overlayFamily) (string, error) {
|
||||
kind := policy.Kind
|
||||
if kind == "" {
|
||||
kind = kindAttribute
|
||||
}
|
||||
if kind != kindAttribute && kind != kindMetric {
|
||||
return "", fmt.Errorf("overlay family %q has unsupported kind %q", current, kind)
|
||||
}
|
||||
return kind, nil
|
||||
}
|
||||
|
||||
func applyOverlay(family *generatedFamily, policy overlayFamily) {
|
||||
if policy.Kind != "" {
|
||||
family.Kind = policy.Kind
|
||||
}
|
||||
if policy.Old != nil {
|
||||
family.Old = append([]string(nil), policy.Old...)
|
||||
}
|
||||
family.Old = appendUnique(family.Old, policy.AddOld...)
|
||||
if len(policy.ExcludeOld) > 0 {
|
||||
excluded := make(map[string]bool, len(policy.ExcludeOld))
|
||||
for _, old := range policy.ExcludeOld {
|
||||
excluded[old] = true
|
||||
}
|
||||
family.Old = deleteMatching(family.Old, excluded)
|
||||
}
|
||||
if policy.Contexts != nil {
|
||||
family.Contexts = append([]string(nil), policy.Contexts...)
|
||||
}
|
||||
if policy.Signals != nil {
|
||||
family.Signals = append([]string(nil), policy.Signals...)
|
||||
}
|
||||
family.Contexts = appendUnique(family.Contexts, policy.AddContexts...)
|
||||
family.Signals = appendUnique(family.Signals, policy.AddSignals...)
|
||||
if policy.ApplyToMetrics != nil {
|
||||
family.ApplyToMetrics = append([]string(nil), policy.ApplyToMetrics...)
|
||||
}
|
||||
family.ApplyToMetrics = appendUnique(family.ApplyToMetrics, policy.AddApplyToMetrics...)
|
||||
if policy.ValueMap != nil {
|
||||
family.ValueMap = make(map[string]string, len(policy.ValueMap))
|
||||
for old, current := range policy.ValueMap {
|
||||
family.ValueMap[old] = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendUnique(values []string, additions ...string) []string {
|
||||
seen := make(map[string]bool, len(values)+len(additions))
|
||||
for _, value := range values {
|
||||
seen[value] = true
|
||||
}
|
||||
for _, value := range additions {
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
values = append(values, value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func deleteMatching(values []string, excluded map[string]bool) []string {
|
||||
result := values[:0]
|
||||
for _, value := range values {
|
||||
if !excluded[value] {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func renderGo(families []generatedFamily) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
out.WriteString("// Code generated by scripts/semconv. DO NOT EDIT.\n\n")
|
||||
out.WriteString("package semconv\n\n")
|
||||
needsTelemetryTypes := false
|
||||
for _, family := range families {
|
||||
if len(family.Contexts) > 0 || len(family.Signals) > 0 {
|
||||
needsTelemetryTypes = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needsTelemetryTypes {
|
||||
out.WriteString("import \"github.com/SigNoz/signoz/pkg/types/telemetrytypes\"\n\n")
|
||||
}
|
||||
out.WriteString("var families = []Family{\n")
|
||||
for _, family := range families {
|
||||
contexts, err := goFieldContextSlice(family.Contexts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render family %q: %w", family.Current, err)
|
||||
}
|
||||
signals, err := goSignalSlice(family.Signals)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render family %q: %w", family.Current, err)
|
||||
}
|
||||
out.WriteString("\t{\n")
|
||||
fmt.Fprintf(&out, "\t\tCurrent: %s,\n", strconv.Quote(family.Current))
|
||||
fmt.Fprintf(&out, "\t\tOld: %s,\n", goStringSlice(family.Old))
|
||||
if family.Kind == kindMetric {
|
||||
out.WriteString("\t\tKind: KindMetric,\n")
|
||||
} else {
|
||||
out.WriteString("\t\tKind: KindAttribute,\n")
|
||||
}
|
||||
fmt.Fprintf(&out, "\t\tContexts: %s,\n", contexts)
|
||||
fmt.Fprintf(&out, "\t\tSignals: %s,\n", signals)
|
||||
fmt.Fprintf(&out, "\t\tApplyToMetrics: %s,\n", goStringSlice(family.ApplyToMetrics))
|
||||
if len(family.ValueMap) > 0 {
|
||||
out.WriteString("\t\tValueMap: map[string]string{\n")
|
||||
keys := sortedMapKeys(family.ValueMap)
|
||||
for _, key := range keys {
|
||||
fmt.Fprintf(&out, "\t\t\t%s: %s,\n", strconv.Quote(key), strconv.Quote(family.ValueMap[key]))
|
||||
}
|
||||
out.WriteString("\t\t},\n")
|
||||
}
|
||||
out.WriteString("\t},\n")
|
||||
}
|
||||
out.WriteString("}\n")
|
||||
return format.Source(out.Bytes())
|
||||
}
|
||||
|
||||
func goStringSlice(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "nil"
|
||||
}
|
||||
quoted := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
quoted[i] = strconv.Quote(value)
|
||||
}
|
||||
return "[]string{" + strings.Join(quoted, ", ") + "}"
|
||||
}
|
||||
|
||||
func goFieldContextSlice(values []string) (string, error) {
|
||||
if len(values) == 0 {
|
||||
return "nil", nil
|
||||
}
|
||||
constants := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
switch value {
|
||||
case "metric":
|
||||
constants[i] = "telemetrytypes.FieldContextMetric"
|
||||
case "resource":
|
||||
constants[i] = "telemetrytypes.FieldContextResource"
|
||||
case "attribute":
|
||||
constants[i] = "telemetrytypes.FieldContextAttribute"
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported field context %q", value)
|
||||
}
|
||||
}
|
||||
return "[]telemetrytypes.FieldContext{" + strings.Join(constants, ", ") + "}", nil
|
||||
}
|
||||
|
||||
func goSignalSlice(values []string) (string, error) {
|
||||
if len(values) == 0 {
|
||||
return "nil", nil
|
||||
}
|
||||
constants := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
switch value {
|
||||
case "traces":
|
||||
constants[i] = "telemetrytypes.SignalTraces"
|
||||
case "logs":
|
||||
constants[i] = "telemetrytypes.SignalLogs"
|
||||
case "metrics":
|
||||
constants[i] = "telemetrytypes.SignalMetrics"
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported signal %q", value)
|
||||
}
|
||||
}
|
||||
return "[]telemetrytypes.Signal{" + strings.Join(constants, ", ") + "}", nil
|
||||
}
|
||||
|
||||
func renderTypeScript(families []generatedFamily) []byte {
|
||||
var out bytes.Buffer
|
||||
out.WriteString("// Code generated by scripts/semconv. DO NOT EDIT.\n\n")
|
||||
out.WriteString("export type SemconvFamily = {\n")
|
||||
out.WriteString("\treadonly current: string;\n\treadonly old: readonly string[];\n")
|
||||
out.WriteString("\treadonly kind: 'attribute' | 'metric';\n")
|
||||
out.WriteString("\treadonly contexts: readonly string[];\n\treadonly signals: readonly string[];\n")
|
||||
out.WriteString("\treadonly applyToMetrics: readonly string[];\n")
|
||||
out.WriteString("\treadonly valueMap: Readonly<Record<string, string>>;\n};\n\n")
|
||||
out.WriteString("export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [\n")
|
||||
for _, family := range families {
|
||||
out.WriteString("\t{\n")
|
||||
fmt.Fprintf(&out, "\t\tcurrent: %s,\n", tsString(family.Current))
|
||||
fmt.Fprintf(&out, "\t\told: %s,\n", tsStringSlice(family.Old))
|
||||
fmt.Fprintf(&out, "\t\tkind: %s,\n", tsString(family.Kind))
|
||||
fmt.Fprintf(&out, "\t\tcontexts: %s,\n", tsStringSlice(family.Contexts))
|
||||
fmt.Fprintf(&out, "\t\tsignals: %s,\n", tsStringSlice(family.Signals))
|
||||
fmt.Fprintf(&out, "\t\tapplyToMetrics: %s,\n", tsStringSlice(family.ApplyToMetrics))
|
||||
out.WriteString("\t\tvalueMap: {")
|
||||
keys := sortedMapKeys(family.ValueMap)
|
||||
for i, key := range keys {
|
||||
if i > 0 {
|
||||
out.WriteString(", ")
|
||||
}
|
||||
fmt.Fprintf(&out, "%s: %s", tsString(key), tsString(family.ValueMap[key]))
|
||||
}
|
||||
out.WriteString("},\n\t},\n")
|
||||
}
|
||||
out.WriteString("] as const;\n")
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func tsString(value string) string {
|
||||
quoted := strconv.Quote(value)
|
||||
return "'" + strings.ReplaceAll(quoted[1:len(quoted)-1], "'", `\'`) + "'"
|
||||
}
|
||||
func tsStringSlice(values []string) string {
|
||||
quoted := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
quoted[i] = tsString(value)
|
||||
}
|
||||
return "[" + strings.Join(quoted, ", ") + "]"
|
||||
}
|
||||
|
||||
func sortedMapKeys[T any](values map[string]T) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func checkFile(path string, expected []byte) error {
|
||||
actual, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generated file %s is missing: run go run ./scripts/semconv", path)
|
||||
}
|
||||
if !bytes.Equal(actual, expected) {
|
||||
return fmt.Errorf("generated file %s is stale: run go run ./scripts/semconv", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSchemaDecoderRejectsUnsupportedSection(t *testing.T) {
|
||||
var schema schemaFile
|
||||
err := decodeKnownFields([]byte(`
|
||||
versions:
|
||||
1.0.0:
|
||||
span_events:
|
||||
changes:
|
||||
- rename_events:
|
||||
event_map:
|
||||
old: current
|
||||
`), &schema)
|
||||
|
||||
assert.ErrorContains(t, err, "field span_events not found", "unsupported schema sections must fail generation")
|
||||
}
|
||||
|
||||
func TestBuildFamiliesRejectsMalformedSchemaVersion(t *testing.T) {
|
||||
var schema schemaFile
|
||||
require.NoError(t, decodeKnownFields([]byte(`
|
||||
versions:
|
||||
latest:
|
||||
spans:
|
||||
changes: []
|
||||
`), &schema), "test schema must decode")
|
||||
|
||||
_, err := buildFamilies([]schemaFile{schema}, overlayFile{})
|
||||
assert.ErrorContains(t, err, `schema version "latest"`, "malformed versions must not be silently reordered")
|
||||
}
|
||||
|
||||
func TestBuildFamiliesResolvesRenameChain(t *testing.T) {
|
||||
var schema schemaFile
|
||||
require.NoError(t, decodeKnownFields([]byte(`
|
||||
versions:
|
||||
4.0.0:
|
||||
spans:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
a: b
|
||||
3.0.0:
|
||||
spans:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
b: c
|
||||
x: c
|
||||
2.0.0:
|
||||
spans:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
a: b
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
a: b
|
||||
`), &schema), "test schema must decode")
|
||||
|
||||
enabled := true
|
||||
families, err := buildFamilies([]schemaFile{schema}, overlayFile{Families: map[string]overlayFamily{
|
||||
"c": {Enabled: &enabled},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []generatedFamily{{
|
||||
Current: "c",
|
||||
Old: []string{"b", "x", "a"},
|
||||
Kind: kindAttribute,
|
||||
Contexts: []string{"attribute"},
|
||||
Signals: []string{"traces"},
|
||||
}}, families, "predecessors should be ordered by distance and then name")
|
||||
}
|
||||
|
||||
func TestBuildFamiliesMapsSchemaSectionsToScopes(t *testing.T) {
|
||||
var schema schemaFile
|
||||
require.NoError(t, decodeKnownFields([]byte(`
|
||||
versions:
|
||||
1.0.0:
|
||||
resources:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
resource.old: resource.current
|
||||
logs:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
log.old: log.current
|
||||
metrics:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
state: cpu.mode
|
||||
apply_to_metrics: [system.cpu.time]
|
||||
- rename_metrics:
|
||||
old.metric: current.metric
|
||||
`), &schema), "test schema must decode")
|
||||
|
||||
enabled := true
|
||||
families, err := buildFamilies([]schemaFile{schema}, overlayFile{Families: map[string]overlayFamily{
|
||||
"resource.current": {Enabled: &enabled},
|
||||
"log.current": {Enabled: &enabled},
|
||||
"cpu.mode": {Enabled: &enabled},
|
||||
"current.metric": {Enabled: &enabled, Kind: kindMetric},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []generatedFamily{
|
||||
{
|
||||
Current: "cpu.mode", Old: []string{"state"}, Kind: kindAttribute,
|
||||
Contexts: []string{"attribute"}, Signals: []string{"metrics"},
|
||||
ApplyToMetrics: []string{"system.cpu.time"},
|
||||
},
|
||||
{
|
||||
Current: "current.metric", Old: []string{"old.metric"}, Kind: kindMetric,
|
||||
Contexts: []string{"metric"}, Signals: []string{"metrics"},
|
||||
},
|
||||
{
|
||||
Current: "log.current", Old: []string{"log.old"}, Kind: kindAttribute,
|
||||
Contexts: []string{"attribute"}, Signals: []string{"logs"},
|
||||
},
|
||||
{
|
||||
Current: "resource.current", Old: []string{"resource.old"}, Kind: kindAttribute,
|
||||
Contexts: []string{"resource"},
|
||||
},
|
||||
}, families, "schema sections should produce their documented signal and context scopes")
|
||||
}
|
||||
|
||||
func TestOverlayAddsFamilyWithoutSchemaHistory(t *testing.T) {
|
||||
enabled := true
|
||||
families, err := buildFamilies(nil, overlayFile{Families: map[string]overlayFamily{
|
||||
"added.current": {
|
||||
Enabled: &enabled,
|
||||
Old: []string{"added.old"},
|
||||
Contexts: []string{"resource"},
|
||||
Signals: []string{"traces"},
|
||||
},
|
||||
}})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []generatedFamily{{
|
||||
Current: "added.current",
|
||||
Old: []string{"added.old"},
|
||||
Kind: kindAttribute,
|
||||
Contexts: []string{"resource"},
|
||||
Signals: []string{"traces"},
|
||||
}}, families, "an explicit overlay family should not require schema history")
|
||||
}
|
||||
|
||||
func TestOverlayOverridesGeneratedFamily(t *testing.T) {
|
||||
var schema schemaFile
|
||||
require.NoError(t, decodeKnownFields([]byte(`
|
||||
versions:
|
||||
1.0.0:
|
||||
spans:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
old: current
|
||||
`), &schema), "test schema must decode")
|
||||
|
||||
enabled := true
|
||||
families, err := buildFamilies([]schemaFile{schema}, overlayFile{Families: map[string]overlayFamily{
|
||||
"current": {
|
||||
Enabled: &enabled,
|
||||
AddOld: []string{"older"},
|
||||
ExcludeOld: []string{"old"},
|
||||
AddContexts: []string{"resource"},
|
||||
AddSignals: []string{"logs"},
|
||||
ValueMap: map[string]string{"legacy": "current"},
|
||||
},
|
||||
}})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []generatedFamily{{
|
||||
Current: "current",
|
||||
Old: []string{"older"},
|
||||
Kind: kindAttribute,
|
||||
Contexts: []string{"attribute", "resource"},
|
||||
Signals: []string{"logs", "traces"},
|
||||
ValueMap: map[string]string{"legacy": "current"},
|
||||
}}, families, "overlay additions and exclusions should be applied to the generated family")
|
||||
}
|
||||
|
||||
func TestOverlayDisablesFamilyWhenDefaultIsEnabled(t *testing.T) {
|
||||
var schema schemaFile
|
||||
require.NoError(t, decodeKnownFields([]byte(`
|
||||
versions:
|
||||
1.0.0:
|
||||
spans:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
old: current
|
||||
`), &schema), "test schema must decode")
|
||||
|
||||
disabled := false
|
||||
families, err := buildFamilies([]schemaFile{schema}, overlayFile{
|
||||
DefaultEnabled: true,
|
||||
Families: map[string]overlayFamily{
|
||||
"current": {Enabled: &disabled},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, families, "an explicitly disabled family must override default_enabled")
|
||||
}
|
||||
|
||||
func TestRenderGoIsDeterministic(t *testing.T) {
|
||||
families := []generatedFamily{{
|
||||
Current: "current", Old: []string{"old"}, Kind: kindAttribute,
|
||||
ValueMap: map[string]string{"b": "2", "a": "1"},
|
||||
}}
|
||||
|
||||
first, err := renderGo(families)
|
||||
require.NoError(t, err)
|
||||
second, err := renderGo(families)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first, second, "Go generation must not depend on map iteration order")
|
||||
}
|
||||
|
||||
func TestRenderGoUsesCanonicalTelemetryTypes(t *testing.T) {
|
||||
families := []generatedFamily{{
|
||||
Current: "current", Old: []string{"old"}, Kind: kindAttribute,
|
||||
Contexts: []string{"resource"}, Signals: []string{"traces"},
|
||||
}}
|
||||
|
||||
output, err := renderGo(families)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(output), "telemetrytypes.FieldContextResource", "generated contexts should use telemetrytypes")
|
||||
assert.Contains(t, string(output), "telemetrytypes.SignalTraces", "generated signals should use telemetrytypes")
|
||||
}
|
||||
|
||||
func TestRenderTypeScriptIsDeterministic(t *testing.T) {
|
||||
families := []generatedFamily{{
|
||||
Current: "current", Old: []string{"old"}, Kind: kindAttribute,
|
||||
ValueMap: map[string]string{"b": "2", "a": "1"},
|
||||
}}
|
||||
|
||||
assert.Equal(t, renderTypeScript(families), renderTypeScript(families), "TypeScript generation must not depend on map iteration order")
|
||||
}
|
||||
|
||||
func TestBuildFamiliesHandlesRenameRollback(t *testing.T) {
|
||||
var schema schemaFile
|
||||
require.NoError(t, decodeKnownFields([]byte(`
|
||||
versions:
|
||||
2.0.0:
|
||||
metrics:
|
||||
changes:
|
||||
- rename_metrics:
|
||||
temporary: original
|
||||
1.0.0:
|
||||
metrics:
|
||||
changes:
|
||||
- rename_metrics:
|
||||
original: temporary
|
||||
`), &schema), "test schema must decode")
|
||||
|
||||
enabled := true
|
||||
families, err := buildFamilies([]schemaFile{schema}, overlayFile{Families: map[string]overlayFamily{
|
||||
"original": {Enabled: &enabled, Kind: kindMetric},
|
||||
}})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []generatedFamily{{
|
||||
Current: "original",
|
||||
Old: []string{"temporary"},
|
||||
Kind: kindMetric,
|
||||
Contexts: []string{"metric"},
|
||||
Signals: []string{"metrics"},
|
||||
}}, families, "the latest rollback destination should remain the family root")
|
||||
}
|
||||
|
||||
func TestBuildFamiliesRejectsSameVersionRenameChain(t *testing.T) {
|
||||
var schema schemaFile
|
||||
require.NoError(t, decodeKnownFields([]byte(`
|
||||
versions:
|
||||
1.0.0:
|
||||
spans:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
x: y
|
||||
y: z
|
||||
`), &schema), "test schema must decode")
|
||||
|
||||
_, err := buildFamilies([]schemaFile{schema}, overlayFile{})
|
||||
assert.ErrorContains(t, err, `same-version attribute rename chain through "y"`, "order-sensitive same-version chains must be rejected")
|
||||
}
|
||||
|
||||
func TestBuildFamiliesRejectsOverlayFamilyWithoutHistory(t *testing.T) {
|
||||
enabled := true
|
||||
_, err := buildFamilies(nil, overlayFile{Families: map[string]overlayFamily{
|
||||
"missing": {Enabled: &enabled},
|
||||
}})
|
||||
|
||||
assert.ErrorContains(t, err, `overlay family "missing" with kind "attribute" is absent`, "an overlay cannot invent a family without old members")
|
||||
}
|
||||
|
||||
func TestBuildFamiliesRejectsEnabledFamilyWithoutOldMembers(t *testing.T) {
|
||||
var schema schemaFile
|
||||
require.NoError(t, decodeKnownFields([]byte(`
|
||||
versions:
|
||||
1.0.0:
|
||||
spans:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
old: current
|
||||
`), &schema), "test schema must decode")
|
||||
|
||||
enabled := true
|
||||
_, err := buildFamilies([]schemaFile{schema}, overlayFile{Families: map[string]overlayFamily{
|
||||
"current": {Enabled: &enabled, ExcludeOld: []string{"old"}},
|
||||
}})
|
||||
assert.ErrorContains(t, err, `enabled family "current" with kind "attribute" has no old members`, "exclude_old cannot empty an enabled family")
|
||||
}
|
||||
|
||||
func TestOverlayKindDefaultsToAttribute(t *testing.T) {
|
||||
var schema schemaFile
|
||||
require.NoError(t, decodeKnownFields([]byte(`
|
||||
versions:
|
||||
1.0.0:
|
||||
spans:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
attribute.old: shared.current
|
||||
metrics:
|
||||
changes:
|
||||
- rename_metrics:
|
||||
metric.old: shared.current
|
||||
`), &schema), "test schema must decode")
|
||||
|
||||
enabled := true
|
||||
families, err := buildFamilies([]schemaFile{schema}, overlayFile{Families: map[string]overlayFamily{
|
||||
"shared.current": {Enabled: &enabled},
|
||||
}})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []generatedFamily{{
|
||||
Current: "shared.current",
|
||||
Old: []string{"attribute.old"},
|
||||
Kind: kindAttribute,
|
||||
Contexts: []string{"attribute"},
|
||||
Signals: []string{"traces"},
|
||||
}}, families, "a kind-less overlay policy should affect only the attribute family")
|
||||
}
|
||||
|
||||
func TestCheckFileReportsStaleOutput(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "generated.go")
|
||||
require.NoError(t, os.WriteFile(path, []byte("old"), 0o600), "test output must be writable")
|
||||
|
||||
assert.ErrorContains(t, checkFile(path, []byte("new")), "is stale", "check mode must reject stale generated output")
|
||||
}
|
||||
|
||||
func TestTypeScriptStringEscapesControlCharacters(t *testing.T) {
|
||||
assert.Equal(t, `'line\n\t\x01\'\\end'`, tsString("line\n\t\x01'\\end"), "generated TypeScript strings must remain valid literals")
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
# SigNoz semantic-convention rollout policy.
|
||||
#
|
||||
# Families are keyed by their current OpenTelemetry name. Schema-derived
|
||||
# families are disabled by default so rollout remains explicit and reversible.
|
||||
default_enabled: false
|
||||
|
||||
families:
|
||||
deployment.environment.name:
|
||||
enabled: true
|
||||
db.system.name:
|
||||
enabled: true
|
||||
@@ -1,760 +0,0 @@
|
||||
|
||||
|
||||
file_format: 1.1.0
|
||||
schema_url: https://opentelemetry.io/schemas/1.42.0
|
||||
versions:
|
||||
1.42.0:
|
||||
metrics:
|
||||
changes:
|
||||
- rename_metrics:
|
||||
v8js.memory.heap.limit: v8js.memory.heap.space.size
|
||||
1.41.1:
|
||||
1.41.0:
|
||||
metrics:
|
||||
changes:
|
||||
- rename_metrics:
|
||||
k8s.container.cpu.limit: k8s.container.cpu.limit.desired
|
||||
k8s.container.cpu.limit_utilization: k8s.container.cpu.limit.utilization
|
||||
k8s.container.cpu.request: k8s.container.cpu.request.desired
|
||||
k8s.container.cpu.request_utilization: k8s.container.cpu.request.utilization
|
||||
k8s.container.memory.limit: k8s.container.memory.limit.desired
|
||||
k8s.container.memory.request: k8s.container.memory.request.desired
|
||||
1.40.0:
|
||||
all:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
feature_flag.evaluation.error.message: feature_flag.error.message
|
||||
metrics:
|
||||
changes:
|
||||
- rename_metrics:
|
||||
system.memory.shared: system.memory.linux.shared
|
||||
1.39.0:
|
||||
all:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
linux.memory.slab.state: system.memory.linux.slab.state
|
||||
peer.service: service.peer.name
|
||||
rpc.connect_rpc.error_code: rpc.response.status_code
|
||||
rpc.connect_rpc.request.metadata: rpc.request.metadata
|
||||
rpc.connect_rpc.response.metadata: rpc.response.metadata
|
||||
rpc.grpc.request.metadata: rpc.request.metadata
|
||||
rpc.grpc.response.metadata: rpc.response.metadata
|
||||
rpc.jsonrpc.request_id: jsonrpc.request.id
|
||||
rpc.jsonrpc.version: jsonrpc.protocol.version
|
||||
rpc.system: rpc.system.name
|
||||
metrics:
|
||||
changes:
|
||||
- rename_metrics:
|
||||
process.open_file_descriptor.count: process.unix.file_descriptor.count
|
||||
system.linux.memory.available: system.memory.linux.available
|
||||
system.linux.memory.slab.usage: system.memory.linux.slab.usage
|
||||
1.38.0:
|
||||
all:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
process.context_switch_type: process.context_switch.type
|
||||
process.paging.fault_type: system.paging.fault.type
|
||||
system.cpu.logical_number: cpu.logical_number
|
||||
system.paging.type: system.paging.fault.type
|
||||
system.process.status: process.state
|
||||
system.processes.status: process.state
|
||||
metrics:
|
||||
changes:
|
||||
- rename_metrics:
|
||||
k8s.cronjob.active_jobs: k8s.cronjob.job.active
|
||||
k8s.daemonset.current_scheduled_nodes: k8s.daemonset.node.current_scheduled
|
||||
k8s.daemonset.desired_scheduled_nodes: k8s.daemonset.node.desired_scheduled
|
||||
k8s.daemonset.misscheduled_nodes: k8s.daemonset.node.misscheduled
|
||||
k8s.daemonset.ready_nodes: k8s.daemonset.node.ready
|
||||
k8s.deployment.available_pods: k8s.deployment.pod.available
|
||||
k8s.deployment.desired_pods: k8s.deployment.pod.desired
|
||||
k8s.hpa.current_pods: k8s.hpa.pod.current
|
||||
k8s.hpa.desired_pods: k8s.hpa.pod.desired
|
||||
k8s.hpa.max_pods: k8s.hpa.pod.max
|
||||
k8s.hpa.min_pods: k8s.hpa.pod.min
|
||||
k8s.job.active_pods: k8s.job.pod.active
|
||||
k8s.job.desired_successful_pods: k8s.job.pod.desired_successful
|
||||
k8s.job.failed_pods: k8s.job.pod.failed
|
||||
k8s.job.max_parallel_pods: k8s.job.pod.max_parallel
|
||||
k8s.job.successful_pods: k8s.job.pod.successful
|
||||
k8s.node.allocatable.cpu: k8s.node.cpu.allocatable
|
||||
k8s.node.allocatable.ephemeral_storage: k8s.node.ephemeral_storage.allocatable
|
||||
k8s.node.allocatable.memory: k8s.node.memory.allocatable
|
||||
k8s.node.allocatable.pods: k8s.node.pod.allocatable
|
||||
k8s.replicaset.available_pods: k8s.replicaset.pod.available
|
||||
k8s.replicaset.desired_pods: k8s.replicaset.pod.desired
|
||||
k8s.replication_controller.available_pods: k8s.replicationcontroller.pod.available
|
||||
k8s.replication_controller.desired_pods: k8s.replicationcontroller.pod.desired
|
||||
k8s.replicationcontroller.available_pods: k8s.replicationcontroller.pod.available
|
||||
k8s.replicationcontroller.desired_pods: k8s.replicationcontroller.pod.desired
|
||||
k8s.statefulset.current_pods: k8s.statefulset.pod.current
|
||||
k8s.statefulset.desired_pods: k8s.statefulset.pod.desired
|
||||
k8s.statefulset.ready_pods: k8s.statefulset.pod.ready
|
||||
k8s.statefulset.updated_pods: k8s.statefulset.pod.updated
|
||||
v8js.heap.space.available_size: v8js.memory.heap.space.available_size
|
||||
v8js.heap.space.physical_size: v8js.memory.heap.space.physical_size
|
||||
1.37.0:
|
||||
all:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
android.state: android.app.state
|
||||
container.runtime: container.runtime.name
|
||||
enduser.role: user.roles
|
||||
gen_ai.openai.request.service_tier: openai.request.service_tier
|
||||
gen_ai.openai.response.service_tier: openai.response.service_tier
|
||||
gen_ai.openai.response.system_fingerprint: openai.response.system_fingerprint
|
||||
gen_ai.system: gen_ai.provider.name
|
||||
ios.state: ios.app.state
|
||||
1.36.0:
|
||||
1.35.0:
|
||||
all:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1698
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
az.namespace: azure.resource_provider.namespace
|
||||
az.service_request_id: azure.service.request.id
|
||||
metrics:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/issues/1800
|
||||
- rename_metrics:
|
||||
system.network.connections: system.network.connection.count
|
||||
1.34.0:
|
||||
metrics:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/2295
|
||||
- rename_metrics:
|
||||
cpu.time: system.cpu.time
|
||||
cpu.utilization: system.cpu.utilization
|
||||
cpu.frequency: system.cpu.frequency
|
||||
1.33.0:
|
||||
all:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1982
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
feature_flag.provider_name: feature_flag.provider.name
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1994
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
feature_flag.evaluation.error.message: error.message
|
||||
1.32.0:
|
||||
all:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1989
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
feature_flag.evaluation.reason: feature_flag.result.reason
|
||||
feature_flag.variant: feature_flag.result.variant
|
||||
metrics:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/2042
|
||||
- rename_metrics:
|
||||
otel.sdk.span.live.count: otel.sdk.span.live
|
||||
otel.sdk.span.ended.count: otel.sdk.span.ended
|
||||
otel.sdk.processor.span.processed.count: otel.sdk.processor.span.processed
|
||||
otel.sdk.exporter.span.inflight.count: otel.sdk.exporter.span.inflight
|
||||
otel.sdk.exporter.span.exported.count: otel.sdk.exporter.span.exported
|
||||
1.31.0:
|
||||
all:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1880
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
android.state: android.app.state
|
||||
io.state: ios.app.state
|
||||
metrics:
|
||||
changes:
|
||||
- rename_metrics:
|
||||
k8s.replication_controller.desired_pods: k8s.replicationcontroller.desired_pods
|
||||
k8s.replication_controller.available_pods: k8s.replicationcontroller.available_pods
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1896
|
||||
- rename_metrics:
|
||||
system.cpu.time: cpu.time
|
||||
system.cpu.utilization: cpu.utilization
|
||||
system.cpu.frequency: cpu.frequency
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1896
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
system.cpu.logical_number: cpu.logical_number
|
||||
1.30.0:
|
||||
all:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1632
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
gen_ai.openai.request.seed: gen_ai.request.seed
|
||||
system.network.state: network.connection.state
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1624
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
code.function: code.function.name
|
||||
code.filepath: code.file.path
|
||||
code.lineno: code.line.number
|
||||
code.column: code.column.number
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1734
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
db.system: db.system.name
|
||||
db.cassandra.coordinator.dc: cassandra.coordinator.dc
|
||||
db.cassandra.coordinator.id: cassandra.coordinator.id
|
||||
db.cassandra.consistency_level: cassandra.consistency.level
|
||||
db.cassandra.idempotence: cassandra.query.idempotent
|
||||
db.cassandra.page_size: cassandra.page.size
|
||||
db.cassandra.speculative_execution_count: cassandra.speculative_execution.count
|
||||
db.cosmosdb.client_id: azure.client.id
|
||||
db.cosmosdb.connection_mode: azure.cosmosdb.connection.mode
|
||||
db.cosmosdb.consistency_level: azure.cosmosdb.consistency.level
|
||||
db.cosmosdb.request_charge: azure.cosmosdb.operation.request_charge
|
||||
db.cosmosdb.request_content_length: azure.cosmosdb.request.body.size
|
||||
db.cosmosdb.regions_contacted: azure.cosmosdb.operation.contacted_regions
|
||||
db.cosmosdb.sub_status_code: azure.cosmosdb.response.sub_status_code
|
||||
db.elasticsearch.node.name: elasticsearch.node.name
|
||||
# db.elasticsearch.path_parts is a template attribute, schema transformation
|
||||
# does not support it, adding as a comment for consistency
|
||||
# db.elasticsearch.path_parts.<key> -> db.operation.parameter.<key>
|
||||
metrics:
|
||||
changes:
|
||||
- rename_metrics:
|
||||
db.client.cosmosdb.operation.request_charge: azure.cosmosdb.client.operation.request_charge
|
||||
db.client.cosmosdb.active_instance.count: azure.cosmosdb.client.active_instance.count
|
||||
|
||||
1.29.0:
|
||||
all:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1520
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
process.executable.build_id.profiling: process.executable.build_id.htlhash
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1383
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
vcs.repository.change.id: vcs.change.id
|
||||
vcs.repository.change.title: vcs.change.title
|
||||
vcs.repository.ref.name: vcs.ref.head.name
|
||||
vcs.repository.ref.revision: vcs.ref.head.revision
|
||||
vcs.repository.ref.type: vcs.ref.head.type
|
||||
metrics:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1492
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
system.device: network.interface.name
|
||||
apply_to_metrics:
|
||||
- container.network.io
|
||||
- system.network.dropped
|
||||
- system.network.errors
|
||||
- system.network.io
|
||||
- system.network.connections
|
||||
1.28.0:
|
||||
metrics:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1422
|
||||
- rename_metrics:
|
||||
messaging.client.published.messages: messaging.client.sent.messages
|
||||
1.27.0:
|
||||
all:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1216
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
tls.client.server_name: server.address
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1075
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
deployment.environment: deployment.environment.name
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1245
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
messaging.kafka.message.offset: messaging.kafka.offset
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/815
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
messaging.kafka.consumer.group: messaging.consumer.group.name
|
||||
messaging.rocketmq.client_group: messaging.consumer.group.name
|
||||
messaging.eventhubs.consumer.group: messaging.consumer.group.name
|
||||
messaging.servicebus.destination.subscription_name: messaging.destination.subscription.name
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1200
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
gen_ai.usage.completion_tokens: gen_ai.usage.output_tokens
|
||||
gen_ai.usage.prompt_tokens: gen_ai.usage.input_tokens
|
||||
spans:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1002
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
db.elasticsearch.cluster.name: db.namespace
|
||||
metrics:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1125
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
db.client.connections.state: db.client.connection.state
|
||||
apply_to_metrics:
|
||||
- db.client.connection.count
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
db.client.connections.pool.name: db.client.connection.pool.name
|
||||
apply_to_metrics:
|
||||
- db.client.connection.count
|
||||
- db.client.connection.idle.max
|
||||
- db.client.connection.idle.min
|
||||
- db.client.connection.max
|
||||
- db.client.connection.pending_requests
|
||||
- db.client.connection.timeouts
|
||||
- db.client.connection.create_time
|
||||
- db.client.connection.wait_time
|
||||
- db.client.connection.use_time
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1006
|
||||
- rename_metrics:
|
||||
messaging.publish.messages: messaging.client.published.messages
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1026
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
system.cpu.state: cpu.mode
|
||||
process.cpu.state: cpu.mode
|
||||
container.cpu.state: cpu.mode
|
||||
apply_to_metrics:
|
||||
- system.cpu.time
|
||||
- system.cpu.utilization
|
||||
- process.cpu.time
|
||||
- process.cpu.utilization
|
||||
- container.cpu.time
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/1265
|
||||
- rename_metrics:
|
||||
jvm.buffer.memory.usage: jvm.buffer.memory.used
|
||||
1.26.0:
|
||||
metrics:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/966
|
||||
- rename_metrics:
|
||||
db.client.connections.usage: db.client.connection.count
|
||||
db.client.connections.idle.max: db.client.connection.idle.max
|
||||
db.client.connections.idle.min: db.client.connection.idle.min
|
||||
db.client.connections.max: db.client.connection.max
|
||||
db.client.connections.pending_requests: db.client.connection.pending_requests
|
||||
db.client.connections.timeouts: db.client.connection.timeouts
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/948
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
messaging.client_id: messaging.client.id
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/909
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
state: db.client.connections.state
|
||||
apply_to_metrics:
|
||||
- db.client.connections.usage
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
pool.name: db.client.connections.pool.name
|
||||
apply_to_metrics:
|
||||
- db.client.connections.usage
|
||||
- db.client.connections.idle.max
|
||||
- db.client.connections.idle.min
|
||||
- db.client.connections.max
|
||||
- db.client.connections.pending_requests
|
||||
- db.client.connections.timeouts
|
||||
- db.client.connections.create_time
|
||||
- db.client.connections.wait_time
|
||||
- db.client.connections.use_time
|
||||
all:
|
||||
changes:
|
||||
# https://github:com/open-telemetry/semantic-conventions/pull/731/
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
enduser.id: user.id
|
||||
|
||||
1.25.0:
|
||||
spans:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/911
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
db.name: db.namespace
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/870
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
db.sql.table: db.collection.name
|
||||
db.mongodb.collection: db.collection.name
|
||||
db.cosmosdb.container: db.collection.name
|
||||
db.cassandra.table: db.collection.name
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/798
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
messaging.kafka.destination.partition: messaging.destination.partition.id
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/875
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
db.operation: db.operation.name
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/913
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
messaging.operation: messaging.operation.type
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/866
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
db.statement: db.query.text
|
||||
metrics:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/484
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
system.processes.status: system.process.status
|
||||
apply_to_metrics:
|
||||
- system.processes.count
|
||||
- rename_metrics:
|
||||
system.processes.count: system.process.count
|
||||
system.processes.created: system.process.created
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/625
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
container.labels: container.label
|
||||
k8s.pod.labels: k8s.pod.label
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/330
|
||||
- rename_metrics:
|
||||
process.threads: process.thread.count
|
||||
process.open_file_descriptors: process.open_file_descriptor.count
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
state: process.cpu.state
|
||||
apply_to_metrics:
|
||||
- process.cpu.time
|
||||
- process.cpu.utilization
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
direction: disk.io.direction
|
||||
apply_to_metrics:
|
||||
- process.disk.io
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
type: process.context_switch_type
|
||||
apply_to_metrics:
|
||||
- process.context_switches
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
direction: network.io.direction
|
||||
apply_to_metrics:
|
||||
- process.network.io
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
type: process.paging.fault_type
|
||||
apply_to_metrics:
|
||||
- process.paging.faults
|
||||
all:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/854
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
message.type: rpc.message.type
|
||||
message.id: rpc.message.id
|
||||
message.compressed_size: rpc.message.compressed_size
|
||||
message.uncompressed_size: rpc.message.uncompressed_size
|
||||
|
||||
1.24.0:
|
||||
metrics:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/536
|
||||
- rename_metrics:
|
||||
jvm.memory.usage: jvm.memory.used
|
||||
jvm.memory.usage_after_last_gc: jvm.memory.used_after_last_gc
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/530
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
system.network.io.direction: network.io.direction
|
||||
system.disk.io.direction: disk.io.direction
|
||||
1.23.1:
|
||||
1.23.0:
|
||||
metrics:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/20
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
thread.daemon: jvm.thread.daemon
|
||||
apply_to_metrics:
|
||||
- jvm.thread.count
|
||||
1.22.0:
|
||||
spans:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/229
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
messaging.message.payload_size_bytes: messaging.message.body.size
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/pull/374
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
http.resend_count: http.request.resend_count
|
||||
metrics:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/224
|
||||
- rename_metrics:
|
||||
http.client.duration: http.client.request.duration
|
||||
http.server.duration: http.server.request.duration
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/241
|
||||
- rename_metrics:
|
||||
process.runtime.jvm.memory.usage: jvm.memory.usage
|
||||
process.runtime.jvm.memory.committed: jvm.memory.committed
|
||||
process.runtime.jvm.memory.limit: jvm.memory.limit
|
||||
process.runtime.jvm.memory.usage_after_last_gc: jvm.memory.usage_after_last_gc
|
||||
process.runtime.jvm.gc.duration: jvm.gc.duration
|
||||
# also https://github.com/open-telemetry/semantic-conventions/pull/252
|
||||
process.runtime.jvm.threads.count: jvm.thread.count
|
||||
# also https://github.com/open-telemetry/semantic-conventions/pull/252
|
||||
process.runtime.jvm.classes.loaded: jvm.class.loaded
|
||||
# also https://github.com/open-telemetry/semantic-conventions/pull/252
|
||||
process.runtime.jvm.classes.unloaded: jvm.class.unloaded
|
||||
# also https://github.com/open-telemetry/semantic-conventions/pull/252
|
||||
# and https://github.com/open-telemetry/semantic-conventions/pull/60
|
||||
process.runtime.jvm.classes.current_loaded: jvm.class.count
|
||||
process.runtime.jvm.cpu.time: jvm.cpu.time
|
||||
process.runtime.jvm.cpu.recent_utilization: jvm.cpu.recent_utilization
|
||||
process.runtime.jvm.memory.init: jvm.memory.init
|
||||
process.runtime.jvm.system.cpu.utilization: jvm.system.cpu.utilization
|
||||
process.runtime.jvm.system.cpu.load_1m: jvm.system.cpu.load_1m
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/253
|
||||
process.runtime.jvm.buffer.usage: jvm.buffer.memory.usage
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/253
|
||||
process.runtime.jvm.buffer.limit: jvm.buffer.memory.limit
|
||||
process.runtime.jvm.buffer.count: jvm.buffer.count
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/20
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
type: jvm.memory.type
|
||||
pool: jvm.memory.pool.name
|
||||
apply_to_metrics:
|
||||
- jvm.memory.usage
|
||||
- jvm.memory.committed
|
||||
- jvm.memory.limit
|
||||
- jvm.memory.usage_after_last_gc
|
||||
- jvm.memory.init
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
name: jvm.gc.name
|
||||
action: jvm.gc.action
|
||||
apply_to_metrics:
|
||||
- jvm.gc.duration
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
daemon: thread.daemon
|
||||
apply_to_metrics:
|
||||
- jvm.threads.count
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
pool: jvm.buffer.pool.name
|
||||
apply_to_metrics:
|
||||
- jvm.buffer.memory.usage
|
||||
- jvm.buffer.memory.limit
|
||||
- jvm.buffer.count
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/89
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
state: system.cpu.state
|
||||
cpu: system.cpu.logical_number
|
||||
apply_to_metrics:
|
||||
- system.cpu.time
|
||||
- system.cpu.utilization
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
state: system.memory.state
|
||||
apply_to_metrics:
|
||||
- system.memory.usage
|
||||
- system.memory.utilization
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
state: system.paging.state
|
||||
apply_to_metrics:
|
||||
- system.paging.usage
|
||||
- system.paging.utilization
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
type: system.paging.type
|
||||
direction: system.paging.direction
|
||||
apply_to_metrics:
|
||||
- system.paging.faults
|
||||
- system.paging.operations
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
device: system.device
|
||||
direction: system.disk.direction
|
||||
apply_to_metrics:
|
||||
- system.disk.io
|
||||
- system.disk.operations
|
||||
- system.disk.io_time
|
||||
- system.disk.operation_time
|
||||
- system.disk.merged
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
device: system.device
|
||||
state: system.filesystem.state
|
||||
type: system.filesystem.type
|
||||
mode: system.filesystem.mode
|
||||
mountpoint: system.filesystem.mountpoint
|
||||
apply_to_metrics:
|
||||
- system.filesystem.usage
|
||||
- system.filesystem.utilization
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
device: system.device
|
||||
direction: system.network.direction
|
||||
protocol: network.protocol
|
||||
state: system.network.state
|
||||
apply_to_metrics:
|
||||
- system.network.dropped
|
||||
- system.network.packets
|
||||
- system.network.errors
|
||||
- system.network.io
|
||||
- system.network.connections
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
status: system.processes.status
|
||||
apply_to_metrics:
|
||||
- system.processes.count
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/247
|
||||
- rename_metrics:
|
||||
http.server.request.size: http.server.request.body.size
|
||||
http.server.response.size: http.server.response.body.size
|
||||
resources:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/178
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
telemetry.auto.version: telemetry.distro.version
|
||||
1.21.0:
|
||||
spans:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/pull/3336
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
messaging.kafka.client_id: messaging.client_id
|
||||
messaging.rocketmq.client_id: messaging.client_id
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/pull/3402
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
# net.peer.(name|port) attributes were usually populated on client side
|
||||
# so they should be usually translated to server.(address|port)
|
||||
# net.host.* attributes were only populated on server side
|
||||
net.host.name: server.address
|
||||
net.host.port: server.port
|
||||
# was only populated on client side
|
||||
net.sock.peer.name: server.socket.domain
|
||||
# net.sock.peer.(addr|port) mapping is not possible
|
||||
# since they applied to both client and server side
|
||||
# were only populated on server side
|
||||
net.sock.host.addr: server.socket.address
|
||||
net.sock.host.port: server.socket.port
|
||||
http.client_ip: client.address
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/pull/3426
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
net.protocol.name: network.protocol.name
|
||||
net.protocol.version: network.protocol.version
|
||||
net.host.connection.type: network.connection.type
|
||||
net.host.connection.subtype: network.connection.subtype
|
||||
net.host.carrier.name: network.carrier.name
|
||||
net.host.carrier.mcc: network.carrier.mcc
|
||||
net.host.carrier.mnc: network.carrier.mnc
|
||||
net.host.carrier.icc: network.carrier.icc
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/pull/3355
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
http.method: http.request.method
|
||||
http.status_code: http.response.status_code
|
||||
http.scheme: url.scheme
|
||||
http.url: url.full
|
||||
http.request_content_length: http.request.body.size
|
||||
http.response_content_length: http.response.body.size
|
||||
metrics:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/semantic-conventions/pull/53
|
||||
- rename_metrics:
|
||||
process.runtime.jvm.cpu.utilization: process.runtime.jvm.cpu.recent_utilization
|
||||
1.20.0:
|
||||
spans:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/pull/3272
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
net.app.protocol.name: net.protocol.name
|
||||
net.app.protocol.version: net.protocol.version
|
||||
1.19.0:
|
||||
spans:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/pull/3209
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
faas.execution: faas.invocation_id
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/pull/3188
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
faas.id: cloud.resource_id
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/pull/3190
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
http.user_agent: user_agent.original
|
||||
resources:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/pull/3190
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
browser.user_agent: user_agent.original
|
||||
1.18.0:
|
||||
1.17.0:
|
||||
spans:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/pull/2957
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
messaging.consumer_id: messaging.consumer.id
|
||||
messaging.protocol: net.app.protocol.name
|
||||
messaging.protocol_version: net.app.protocol.version
|
||||
messaging.destination: messaging.destination.name
|
||||
messaging.temp_destination: messaging.destination.temporary
|
||||
messaging.destination_kind: messaging.destination.kind
|
||||
messaging.message_id: messaging.message.id
|
||||
messaging.conversation_id: messaging.message.conversation_id
|
||||
messaging.message_payload_size_bytes: messaging.message.payload_size_bytes
|
||||
messaging.message_payload_compressed_size_bytes: messaging.message.payload_compressed_size_bytes
|
||||
messaging.rabbitmq.routing_key: messaging.rabbitmq.destination.routing_key
|
||||
messaging.kafka.message_key: messaging.kafka.message.key
|
||||
messaging.kafka.partition: messaging.kafka.destination.partition
|
||||
messaging.kafka.tombstone: messaging.kafka.message.tombstone
|
||||
messaging.rocketmq.message_type: messaging.rocketmq.message.type
|
||||
messaging.rocketmq.message_tag: messaging.rocketmq.message.tag
|
||||
messaging.rocketmq.message_keys: messaging.rocketmq.message.keys
|
||||
messaging.kafka.consumer_group: messaging.kafka.consumer.group
|
||||
1.16.0:
|
||||
1.15.0:
|
||||
spans:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/pull/2743
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
http.retry_count: http.resend_count
|
||||
1.14.0:
|
||||
1.13.0:
|
||||
spans:
|
||||
changes:
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/pull/2614
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
net.peer.ip: net.sock.peer.addr
|
||||
net.host.ip: net.sock.host.addr
|
||||
1.12.0:
|
||||
1.11.0:
|
||||
1.10.0:
|
||||
1.9.0:
|
||||
1.8.0:
|
||||
spans:
|
||||
changes:
|
||||
- rename_attributes:
|
||||
attribute_map:
|
||||
db.cassandra.keyspace: db.name
|
||||
db.hbase.namespace: db.name
|
||||
1.7.0:
|
||||
1.6.1:
|
||||
1.5.0:
|
||||
1.4.0:
|
||||
311
tests/e2e/helpers/dashboards-v2.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { authToken } from './dashboards';
|
||||
|
||||
// Helpers for the V2 dashboard detail page (`DashboardPageV2`), which now serves
|
||||
// /dashboard/:id unconditionally. The V1 helpers in ./dashboards.ts still cover
|
||||
// seeding through the v1 API and the list page.
|
||||
//
|
||||
// Interaction contracts encoded here rather than in each spec:
|
||||
// - a multi-select variable commits on dropdown CLOSE, not per toggle;
|
||||
// - an ALL selection renders as an overlay reading "ALL", not as tags;
|
||||
// - a variable is only settled once its options have arrived.
|
||||
|
||||
export const dashboardV2Path = (id: string): string => `/dashboard/${id}`;
|
||||
|
||||
/** Perses-style spec the v2 API stores. Only what the specs need is typed. */
|
||||
export interface DashboardV2Spec {
|
||||
display: { name: string; description?: string };
|
||||
layouts: unknown[];
|
||||
panels: Record<string, unknown>;
|
||||
variables: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const SCHEMA_VERSION = 'v6';
|
||||
|
||||
/** An empty but valid spec — the base every fixture spreads over. */
|
||||
export function emptyV2Spec(name: string): DashboardV2Spec {
|
||||
return { display: { name }, layouts: [], panels: {}, variables: [] };
|
||||
}
|
||||
|
||||
// ─── Seeding through the v2 API ───────────────────────────────────────────
|
||||
//
|
||||
// Specs seed the shape they assert against, rather than relying on whatever the
|
||||
// v1 -> v2 migration happens to produce or on telemetry that ambient data may or
|
||||
// may not contain. Migration output is covered on its own, from the v1 fixtures.
|
||||
|
||||
export async function createDashboardV2ViaApi(
|
||||
page: Page,
|
||||
name: string,
|
||||
spec?: Partial<DashboardV2Spec>,
|
||||
): Promise<string> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v2/dashboards', {
|
||||
data: {
|
||||
name,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
tags: [],
|
||||
// `name` wins over any display name the fixture carries, so a fixture can be
|
||||
// seeded twice under two titles and each spec can still find its own.
|
||||
spec: {
|
||||
...emptyV2Spec(name),
|
||||
...spec,
|
||||
display: { ...spec?.display, name },
|
||||
},
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`POST /api/v2/dashboards ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
const body = (await res.json()) as { data?: { id?: string } };
|
||||
const id = body.data?.id;
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
`POST /api/v2/dashboards returned no id: ${JSON.stringify(body)}`,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function getDashboardV2(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<{ spec: DashboardV2Spec; [key: string]: unknown }> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get(`/api/v2/dashboards/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`GET /api/v2/dashboards/${id} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
data: { spec: DashboardV2Spec; [key: string]: unknown };
|
||||
};
|
||||
return body.data;
|
||||
}
|
||||
|
||||
export async function deleteDashboardV2ViaApi(
|
||||
request: APIRequestContext,
|
||||
id: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
await request.delete(`/api/v2/dashboards/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Variables bar ────────────────────────────────────────────────────────
|
||||
|
||||
export const variablesBar = (page: Page): Locator =>
|
||||
page.getByTestId('dashboard-variables-bar');
|
||||
|
||||
/** The pill for one variable: its name, the control, and (while loading) a spinner. */
|
||||
export const variablePill = (page: Page, name: string): Locator =>
|
||||
page.getByTestId(`variable-${name}`);
|
||||
|
||||
/** List variables (query / custom / dynamic) — the select control. */
|
||||
export const variableControl = (page: Page, name: string): Locator =>
|
||||
page.getByTestId(`variable-select-${name}`);
|
||||
|
||||
/** Text variables — a plain input, not a select. */
|
||||
export const variableTextInput = (page: Page, name: string): Locator =>
|
||||
page.getByTestId(`variable-input-${name}`);
|
||||
|
||||
/**
|
||||
* The bar collapses variables that do not fit into a "+N" button. At the config's
|
||||
* 1280px viewport that starts with the second variable, so a spec asserting on
|
||||
* several pills at once must widen the viewport:
|
||||
*
|
||||
* test.use({ viewport: WIDE_VIEWPORT });
|
||||
*/
|
||||
export const WIDE_VIEWPORT = { width: 1920, height: 1080 };
|
||||
|
||||
/**
|
||||
* The overflow ("+N") tooltip listing the collapsed variables. `.first()` because the
|
||||
* tooltip primitive renders its content twice — once visible, once as an a11y copy —
|
||||
* so an unscoped locator is a strict-mode violation rather than a missing element.
|
||||
*/
|
||||
export const hiddenVariablesTooltip = (page: Page): Locator =>
|
||||
page.getByTestId('hidden-variables-tooltip').first();
|
||||
|
||||
/** Resolved when the variable's options have arrived and its spinner is gone. */
|
||||
export async function awaitVariableSettled(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
await expect(variablePill(page, name)).toBeVisible();
|
||||
await expect(page.getByTestId(`variable-loading-${name}`)).toBeHidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* What a list variable's closed control shows — "ALL" for an ALL selection, else its
|
||||
* tags. Asserts the control exists first: a missing one (wrong name, or a text
|
||||
* variable, which uses {@link variableTextInput}) otherwise hangs until the test
|
||||
* times out with nothing to point at.
|
||||
*/
|
||||
export async function readVariableSelection(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
const pill = variablePill(page, name);
|
||||
await expect(pill).toBeVisible();
|
||||
// The ALL overlay sits in the control's wrapper, as a SIBLING of the element
|
||||
// carrying the testid — scope from the pill, or an ALL selection reads as empty.
|
||||
const allOverlay = pill.locator('.all-text');
|
||||
if ((await allOverlay.count()) > 0 && (await allOverlay.isVisible())) {
|
||||
return (await allOverlay.textContent())?.trim() ?? '';
|
||||
}
|
||||
return (await variableControl(page, name).innerText()).trim();
|
||||
}
|
||||
|
||||
/** The open option list, whichever control opened it. */
|
||||
export const anyDropdown = (page: Page): Locator =>
|
||||
page.locator('.custom-multiselect-dropdown, .custom-select-dropdown');
|
||||
|
||||
export async function openVariableDropdown(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
await awaitVariableSettled(page, name);
|
||||
await variableControl(page, name).click();
|
||||
await expect(anyDropdown(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the open dropdown, which is what commits a multi-select edit. Pressing
|
||||
* Escape leaves the control focused without re-opening it, unlike clicking away.
|
||||
*/
|
||||
export async function closeVariableDropdown(page: Page): Promise<void> {
|
||||
// Escape closes it when the control still holds focus, which a row click can move.
|
||||
// Falling back to a click outside covers that, and is what a user does anyway —
|
||||
// either way the close is what commits the edit.
|
||||
await page.keyboard.press('Escape');
|
||||
if (await anyDropdown(page).first().isVisible()) {
|
||||
await page.getByTestId('dashboard-title').click();
|
||||
}
|
||||
await expect(anyDropdown(page).first()).toBeHidden();
|
||||
}
|
||||
|
||||
const escapeForRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/**
|
||||
* One option row in the open dropdown, matched on its label element rather than the
|
||||
* row's accessible name: hovering reveals the Only / Toggle buttons, whose text joins
|
||||
* that name, so a name-based locator stops matching halfway through an interaction.
|
||||
*/
|
||||
export function optionRow(page: Page, value: string): Locator {
|
||||
const exact = new RegExp(`^${escapeForRegExp(value)}$`);
|
||||
// Multi-select rows carry a `.option-label-text` and, once hovered, Only / Toggle
|
||||
// buttons whose text would break an exact match on the row itself. Single-select
|
||||
// rows have neither — just the label — so each shape needs its own matcher.
|
||||
const multi = page
|
||||
.locator('.custom-multiselect-dropdown .option-item')
|
||||
.filter({ has: page.locator('.option-label-text', { hasText: exact }) });
|
||||
const single = page
|
||||
.locator('.custom-select-dropdown .option-item')
|
||||
.filter({ hasText: exact });
|
||||
return multi.or(single);
|
||||
}
|
||||
|
||||
export async function pickVariableValues(
|
||||
page: Page,
|
||||
name: string,
|
||||
values: string[],
|
||||
): Promise<void> {
|
||||
await openVariableDropdown(page, name);
|
||||
const [first, ...rest] = values;
|
||||
|
||||
// Start from the row's "Only" button rather than its checkbox: an ALL selection
|
||||
// opens with every option checked, so a click would UNcheck the wanted value and
|
||||
// leave the rest selected. "Only" collapses to exactly this option either way,
|
||||
// and the clear icon is deliberately unavailable while the draft is all.
|
||||
// Wait for each target to be visible before acting: the dropdown re-renders as
|
||||
// options resolve, and a click on a row that is still arriving (or has just been
|
||||
// replaced) hangs until the test times out.
|
||||
const firstRow = optionRow(page, first);
|
||||
await expect(firstRow).toBeVisible();
|
||||
await firstRow.hover();
|
||||
const onlyButton = firstRow.locator('.only-btn');
|
||||
await expect(onlyButton).toBeVisible();
|
||||
await onlyButton.click();
|
||||
|
||||
// Additional values then add to that selection. Click the row's checkbox, not the
|
||||
// row: the row body carries no toggle handler, so clicking it leaves the option
|
||||
// unchecked and the value silently absent from the commit.
|
||||
for (const value of rest) {
|
||||
const row = optionRow(page, value);
|
||||
await expect(row).toBeVisible();
|
||||
const checkbox = row.locator('.option-checkbox');
|
||||
if ((await checkbox.count()) > 0) {
|
||||
await checkbox.first().click();
|
||||
} else {
|
||||
await row.click();
|
||||
}
|
||||
}
|
||||
await closeVariableDropdown(page);
|
||||
}
|
||||
|
||||
/** Type a value the option list does not offer, and commit it. */
|
||||
export async function typeVariableValue(
|
||||
page: Page,
|
||||
name: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
await openVariableDropdown(page, name);
|
||||
await page.keyboard.type(value);
|
||||
const dropdown = page.locator('.custom-multiselect-dropdown');
|
||||
await dropdown.getByText(value, { exact: true }).first().click();
|
||||
await closeVariableDropdown(page);
|
||||
}
|
||||
|
||||
// ─── Panels and sections ──────────────────────────────────────────────────
|
||||
|
||||
// A section's id is derived from its first panel's key, e.g. panel `p-timeseries` gives
|
||||
// section `sec-p-timeseries` — stable for a seeded fixture, since the keys are ours.
|
||||
export const sectionId = (firstPanelKey: string): string =>
|
||||
`sec-${firstPanelKey}`;
|
||||
|
||||
export const section = (page: Page, firstPanelKey: string): Locator =>
|
||||
page.getByTestId(`dashboard-section-${sectionId(firstPanelKey)}`);
|
||||
|
||||
export const sectionToggle = (page: Page, firstPanelKey: string): Locator =>
|
||||
page.getByTestId(`dashboard-section-toggle-${sectionId(firstPanelKey)}`);
|
||||
|
||||
export const panelActions = (page: Page, panelKey: string): Locator =>
|
||||
page.getByTestId(`panel-actions-${panelKey}`);
|
||||
|
||||
/** A panel by its display name — panels carry no per-panel testid on the card itself. */
|
||||
export const panelByTitle = (page: Page, title: string): Locator =>
|
||||
page.getByText(title, { exact: true });
|
||||
|
||||
/** Resolved when no panel on the page is still fetching. */
|
||||
export async function awaitPanelsSettled(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId('panel-refetching')).toHaveCount(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The values currently checked in a multi-select's list, read from the open dropdown —
|
||||
* the closed control shows at most one tag plus a "+N", so it cannot confirm a
|
||||
* multi-value selection on its own. Excludes the aggregate ALL row.
|
||||
*/
|
||||
export async function readCheckedOptions(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<string[]> {
|
||||
await openVariableDropdown(page, name);
|
||||
const labels = await page
|
||||
.locator(
|
||||
'.custom-multiselect-dropdown .option-item[aria-selected="true"]:not(.all-option) .option-label-text',
|
||||
)
|
||||
.allInnerTexts();
|
||||
await closeVariableDropdown(page);
|
||||
return labels.map((label) => label.trim());
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
"fmt:check": "oxfmt --check .",
|
||||
"lint": "oxlint .",
|
||||
"lint:fix": "oxlint . --fix",
|
||||
"guard:specs": "node scripts/guard-specs.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
20
tests/e2e/parked-specs.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$comment": "Specs not currently running, and why. This is the ONLY place a spec may be excluded from the suite: `playwright.config.ts` feeds `specs` to `testIgnore`, and `pnpm guard:specs` fails if any spec that is NOT listed here contains a skipped, fixme'd or .only test. So a spec is either running and complete, or parked here with a reason — nothing rots quietly in between. Every entry is removed by the PR that migrates it; the list only shrinks.",
|
||||
"specs": [
|
||||
"**/tests/dashboards/list.spec.ts",
|
||||
"**/tests/dashboards/details/03-viewing.spec.ts",
|
||||
"**/tests/dashboards/details/21-panel-actions.spec.ts",
|
||||
"**/tests/dashboards/details/35-add-panel.spec.ts",
|
||||
"**/tests/dashboards/details/44-edit-panel.spec.ts",
|
||||
"**/tests/dashboards/details/56-time-range.spec.ts",
|
||||
"**/tests/dashboards/details/67-variables.spec.ts",
|
||||
"**/tests/dashboards/details/78-edit-mode.spec.ts",
|
||||
"**/tests/dashboards/details/87-configure.spec.ts",
|
||||
"**/tests/dashboards/details/95-edge-cases.spec.ts",
|
||||
"**/tests/trace-details/preview-fields.spec.ts"
|
||||
],
|
||||
"reasons": {
|
||||
"**/tests/dashboards/**": "Written against V1 dashboard behaviour; the V1 -> V2 migration changed what they assert. Being rewritten area by area — see the E0-E8 plan.",
|
||||
"**/tests/trace-details/preview-fields.spec.ts": "Entirely `describe.skip` since it was added: the hover card's preview field needs seeded telemetry the suite does not provide yet."
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,23 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// .env holds user-provided defaults (staging creds).
|
||||
// .env.local is written by tests/e2e/bootstrap/setup.py when the pytest
|
||||
// lifecycle brings the backend up locally; override=true so local-backend
|
||||
// coordinates win over any stale .env values. Subprocess-injected env
|
||||
// (e.g. when pytest shells out to `pnpm test`) still takes priority —
|
||||
// dotenv doesn't touch vars that are already set in process.env.
|
||||
import parkedSpecs from './parked-specs.json';
|
||||
|
||||
// Precedence: real env > .env.local > .env. dotenv never overwrites a var that is
|
||||
// already set, so loading in that order gives local-backend coordinates (.env.local,
|
||||
// written by bootstrap/setup.py) priority over the staging defaults in .env, while an
|
||||
// explicitly exported var still wins over both — which is what lets a run be pointed
|
||||
// at another environment without editing a generated file.
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env.local') });
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env.local'), override: true });
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
|
||||
// Temporarily excluded: the V1 -> V2 dashboard migration changes the
|
||||
// behaviour the dashboards specs assert against, so they fail as written.
|
||||
// Remove this once they are updated for the V2 dashboard.
|
||||
testIgnore: ['**/tests/dashboards/**'],
|
||||
// Parked specs, listed one by one with a reason in parked-specs.json — not a
|
||||
// blanket glob, so nothing new can land inside an excluded directory unnoticed.
|
||||
// `pnpm guard:specs` keeps this list and the suite honest.
|
||||
testIgnore: parkedSpecs.specs,
|
||||
|
||||
// All Playwright output lands under artifacts/. One subdir per reporter
|
||||
// plus results/ for per-test artifacts (traces/screenshots/videos).
|
||||
|
||||
91
tests/e2e/scripts/guard-specs.mjs
Normal file
@@ -0,0 +1,91 @@
|
||||
// Keeps the suite honest: a spec is either running and complete, or parked in
|
||||
// parked-specs.json with a reason. Fails on a skipped/fixme'd/only test in a spec that
|
||||
// is not parked, and on a parked entry that no longer matches anything.
|
||||
//
|
||||
// Run: pnpm guard:specs
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const parked = JSON.parse(
|
||||
readFileSync(join(root, 'parked-specs.json'), 'utf8'),
|
||||
);
|
||||
|
||||
/** Every *.spec.ts under tests/, repo-relative with forward slashes. */
|
||||
function specFiles(dir) {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return specFiles(full);
|
||||
}
|
||||
return entry.name.endsWith('.spec.ts') ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** A parked glob (`**/tests/x/y.spec.ts`) matched against an absolute path. */
|
||||
function matchesGlob(glob, absolutePath) {
|
||||
const ANY_DIRS = '\u0000';
|
||||
const pattern = glob
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*\*\//g, ANY_DIRS)
|
||||
// Single `*` never crosses a path separator; do this before expanding ANY_DIRS,
|
||||
// whose replacement itself contains a `*`.
|
||||
.replace(/\*/g, '[^/]*')
|
||||
.split(ANY_DIRS)
|
||||
.join('(?:.*/)?');
|
||||
return new RegExp(`^${pattern}$`).test(absolutePath.split('\\').join('/'));
|
||||
}
|
||||
|
||||
// Declaration form only — `test.skip(condition, reason)` inside a test body is a
|
||||
// legitimate runtime guard, not a parked test.
|
||||
const OFFENDERS = [
|
||||
{ label: 'test.skip', re: /(?<![\w.])test\.skip\(\s*['"`]/g },
|
||||
{ label: 'test.fixme', re: /(?<![\w.])test\.fixme\(\s*['"`]/g },
|
||||
{ label: 'describe.skip', re: /describe\.skip\(/g },
|
||||
{ label: 'describe.fixme', re: /describe\.fixme\(/g },
|
||||
{ label: 'test.only', re: /(?<![\w.])test\.only\(/g },
|
||||
{ label: 'describe.only', re: /describe\.only\(/g },
|
||||
];
|
||||
|
||||
const testsDir = join(root, 'tests');
|
||||
const files = statSync(testsDir, { throwIfNoEntry: false })
|
||||
? specFiles(testsDir)
|
||||
: [];
|
||||
const failures = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (parked.specs.some((glob) => matchesGlob(glob, file))) {
|
||||
continue;
|
||||
}
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const { label, re } of OFFENDERS) {
|
||||
const hits = source.match(re);
|
||||
if (hits) {
|
||||
failures.push(
|
||||
`${relative(root, file)}: ${hits.length} × ${label} — finish it, or park the spec in parked-specs.json with a reason`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const glob of parked.specs) {
|
||||
if (!files.some((file) => matchesGlob(glob, file))) {
|
||||
failures.push(
|
||||
`parked-specs.json: "${glob}" matches no spec — drop the stale entry`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
`\nguard:specs failed\n\n${failures.map((f) => ` ✗ ${f}`).join('\n')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`guard:specs ok — ${files.length - parked.specs.length}/${files.length} specs running, ${parked.specs.length} parked`,
|
||||
);
|
||||
363
tests/e2e/testdata/sections-dashboard-v2.json
vendored
Normal file
@@ -0,0 +1,363 @@
|
||||
{
|
||||
"$comment": "Three grid sections with two panels each, trimmed from a real V2 dashboard so the structure (sections, panels, layout refs, collapse state, plugin kinds) is faithful without depending on any particular telemetry. Panel queries target signals this stack may hold nothing for — that is fine: these specs assert on structure and chrome, never on chart values. Variables are text + custom, whose options come from the definition.",
|
||||
"spec": {
|
||||
"display": { "name": "sections-v2", "description": "" },
|
||||
"variables": [
|
||||
{
|
||||
"kind": "TextVariable",
|
||||
"spec": {
|
||||
"name": "textbox.environment",
|
||||
"display": { "name": "textbox.environment", "description": "" },
|
||||
"value": "prod",
|
||||
"constant": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "custom.service.name",
|
||||
"display": { "name": "custom.service.name", "description": "" },
|
||||
"allowMultiple": true,
|
||||
"allowAllValue": true,
|
||||
"sort": "alphabetical-asc",
|
||||
"plugin": {
|
||||
"kind": "signoz/CustomVariable",
|
||||
"spec": { "customValue": "checkout,payments,cart" }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"panels": {
|
||||
"p-timeseries": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": { "name": "Requests over time", "description": "" },
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": { "timePreference": "global_time", "fillSpans": false },
|
||||
"formatting": { "unit": "none", "decimalPrecision": "2" },
|
||||
"legend": { "position": "bottom", "mode": "list", "customColors": null }
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"source": "",
|
||||
"aggregations": [{ "expression": "count()" }],
|
||||
"disabled": false,
|
||||
"filter": { "expression": "service.name IN $custom.service.name" },
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"signal": "",
|
||||
"fieldContext": "resource",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": { "expression": "" },
|
||||
"functions": [],
|
||||
"legend": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"p-table": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": { "name": "Requests by pod", "description": "" },
|
||||
"plugin": {
|
||||
"kind": "signoz/TablePanel",
|
||||
"spec": {
|
||||
"visualization": { "timePreference": "global_time" },
|
||||
"formatting": { "columnUnits": { "A": "" }, "decimalPrecision": "2" },
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "scalar",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"source": "",
|
||||
"aggregations": [{ "expression": "count()" }],
|
||||
"disabled": false,
|
||||
"filter": { "expression": "service.name IN $custom.service.name" },
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "k8s.pod.name",
|
||||
"signal": "",
|
||||
"fieldContext": "resource",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": { "expression": "" },
|
||||
"functions": [],
|
||||
"legend": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"p-promql": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": { "name": "Duration rate (PromQL)", "description": "" },
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": { "timePreference": "global_time", "fillSpans": false },
|
||||
"formatting": { "unit": "none", "decimalPrecision": "2" },
|
||||
"legend": { "position": "bottom", "mode": "list", "customColors": null }
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/PromQLQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "sum by (\"service.name\") (rate({\"http.server.duration.count\"}[5m]))",
|
||||
"disabled": false,
|
||||
"step": 0,
|
||||
"stats": false,
|
||||
"legend": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"p-number": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": { "name": "Total requests", "description": "" },
|
||||
"plugin": {
|
||||
"kind": "signoz/NumberPanel",
|
||||
"spec": {
|
||||
"visualization": { "timePreference": "global_time" },
|
||||
"formatting": { "unit": "none", "decimalPrecision": "2" },
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "scalar",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/PromQLQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "sum(rate({\"http.server.duration.count\"}[5m]))",
|
||||
"disabled": false,
|
||||
"step": 0,
|
||||
"stats": false,
|
||||
"legend": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"p-pie": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": { "name": "Split by environment", "description": "" },
|
||||
"plugin": {
|
||||
"kind": "signoz/PieChartPanel",
|
||||
"spec": {
|
||||
"visualization": { "timePreference": "global_time" },
|
||||
"formatting": { "unit": "none", "decimalPrecision": "2" },
|
||||
"legend": { "position": "bottom", "mode": "list", "customColors": null }
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "scalar",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"source": "",
|
||||
"aggregations": [{ "expression": "count()" }],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "deployment.environment = $textbox.environment"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"signal": "",
|
||||
"fieldContext": "resource",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": { "expression": "" },
|
||||
"functions": [],
|
||||
"legend": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"p-bar": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": { "name": "Stacked by pod", "description": "" },
|
||||
"plugin": {
|
||||
"kind": "signoz/BarChartPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false,
|
||||
"stackedBarChart": true
|
||||
},
|
||||
"formatting": { "unit": "none", "decimalPrecision": "2" },
|
||||
"legend": { "position": "bottom", "mode": "list", "customColors": null },
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"source": "",
|
||||
"aggregations": [{ "expression": "count()" }],
|
||||
"disabled": false,
|
||||
"filter": { "expression": "" },
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "k8s.pod.name",
|
||||
"signal": "",
|
||||
"fieldContext": "resource",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": { "expression": "" },
|
||||
"functions": [],
|
||||
"legend": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": { "title": "Query Builder", "collapse": { "open": true } },
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": { "$ref": "#/spec/panels/p-timeseries" }
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": { "$ref": "#/spec/panels/p-table" }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": { "title": "PromQL", "collapse": { "open": true } },
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": { "$ref": "#/spec/panels/p-promql" }
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": { "$ref": "#/spec/panels/p-number" }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": { "title": "Mixed", "collapse": { "open": true } },
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": { "$ref": "#/spec/panels/p-pie" }
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": { "$ref": "#/spec/panels/p-bar" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"duration": "",
|
||||
"refreshInterval": "",
|
||||
"links": []
|
||||
}
|
||||
}
|
||||
48
tests/e2e/testdata/variables-dashboard-v2.json
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$comment": "V2 (Perses-shape) dashboard spec seeded through POST /api/v2/dashboards. Only text and custom variables, so it resolves without telemetry: option lists are fixed by the definition, which keeps assertions on them deterministic. Query and dynamic variables are seeded per-spec alongside the telemetry they need.",
|
||||
"spec": {
|
||||
"display": { "name": "variables-v2", "description": "" },
|
||||
"layouts": [],
|
||||
"panels": {},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "TextVariable",
|
||||
"spec": {
|
||||
"name": "tb_env",
|
||||
"display": { "name": "tb_env", "description": "Free-text environment" },
|
||||
"value": "prod",
|
||||
"constant": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "cu_service",
|
||||
"display": { "name": "cu_service", "description": "Multi-select with ALL" },
|
||||
"allowMultiple": true,
|
||||
"allowAllValue": true,
|
||||
"sort": "none",
|
||||
"plugin": {
|
||||
"kind": "signoz/CustomVariable",
|
||||
"spec": { "customValue": "checkout,payments,cart" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "cu_region",
|
||||
"display": { "name": "cu_region", "description": "Single-select with a default" },
|
||||
"allowMultiple": false,
|
||||
"allowAllValue": false,
|
||||
"sort": "none",
|
||||
"defaultValue": "eu-west",
|
||||
"plugin": {
|
||||
"kind": "signoz/CustomVariable",
|
||||
"spec": { "customValue": "us-east,eu-west" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
110
tests/e2e/tests/dashboards/details/01-smoke.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import { authToken } from '../../../helpers/dashboards';
|
||||
import {
|
||||
createDashboardV2ViaApi,
|
||||
dashboardV2Path,
|
||||
deleteDashboardV2ViaApi,
|
||||
pickVariableValues,
|
||||
readVariableSelection,
|
||||
variablePill,
|
||||
variablesBar,
|
||||
variableTextInput,
|
||||
WIDE_VIEWPORT,
|
||||
} from '../../../helpers/dashboards-v2';
|
||||
import customVariables from '../../../testdata/variables-dashboard-v2.json';
|
||||
|
||||
// The foundation the other dashboards specs build on: seeding a V2 spec through the
|
||||
// v2 API, opening it, and driving the variables bar. Everything here is deterministic
|
||||
// — custom and text variables need no telemetry, so this spec cannot go red because
|
||||
// of what the stack happens to hold.
|
||||
|
||||
test.use({ viewport: WIDE_VIEWPORT });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let dashboardId = '';
|
||||
|
||||
// Per worker: `beforeAll` runs once in each, and the v2 API rejects a duplicate name.
|
||||
const SUITE_TITLE = `detail-smoke-suite-${process.env.TEST_WORKER_INDEX ?? '0'}`;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
dashboardId = await createDashboardV2ViaApi(
|
||||
page,
|
||||
SUITE_TITLE,
|
||||
customVariables.spec,
|
||||
);
|
||||
seedIds.add(dashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardV2ViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboard detail — V2 foundation', () => {
|
||||
test('TC-01 a seeded V2 dashboard opens with its title and variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(dashboardV2Path(dashboardId));
|
||||
|
||||
await expect(page.getByTestId('dashboard-title')).toContainText(SUITE_TITLE);
|
||||
await expect(variablesBar(page)).toBeVisible();
|
||||
for (const name of ['tb_env', 'cu_service', 'cu_region']) {
|
||||
await expect(variablePill(page, name)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 a text variable renders the value it was seeded with', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(dashboardV2Path(dashboardId));
|
||||
|
||||
await expect(variableTextInput(page, 'tb_env')).toHaveValue('prod');
|
||||
});
|
||||
|
||||
test('TC-03 an ALL-enabled multi-select reads ALL until a value is picked', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(dashboardV2Path(dashboardId));
|
||||
|
||||
// Seeded with allowAllValue and no default, so it resolves to ALL.
|
||||
await expect
|
||||
.poll(() => readVariableSelection(page, 'cu_service'))
|
||||
.toBe('ALL');
|
||||
|
||||
// A multi-select commits when the dropdown closes, not per toggle.
|
||||
await pickVariableValues(page, 'cu_service', ['checkout']);
|
||||
|
||||
await expect
|
||||
.poll(() => readVariableSelection(page, 'cu_service'))
|
||||
.toContain('checkout');
|
||||
});
|
||||
|
||||
test('TC-04 a single-select renders its configured default', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(dashboardV2Path(dashboardId));
|
||||
|
||||
await expect
|
||||
.poll(() => readVariableSelection(page, 'cu_region'))
|
||||
.toContain('eu-west');
|
||||
});
|
||||
});
|
||||
@@ -2,538 +2,156 @@ import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import { authToken } from '../../../helpers/dashboards';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
createDashboardV2ViaApi,
|
||||
dashboardV2Path,
|
||||
deleteDashboardV2ViaApi,
|
||||
panelActions,
|
||||
panelByTitle,
|
||||
section,
|
||||
sectionToggle,
|
||||
variablesBar,
|
||||
WIDE_VIEWPORT,
|
||||
} from '../../../helpers/dashboards-v2';
|
||||
import sectionsFixture from '../../../testdata/sections-dashboard-v2.json';
|
||||
|
||||
// ─── Per-test seed lifecycle ────────────────────────────────────────────
|
||||
//
|
||||
// Each test gets its own freshly-seeded APM Metrics dashboard (4 sections,
|
||||
// 16 panels — including the duplicate-named "Overview" sections, which the
|
||||
// fixture intentionally ships). Per-test seeding eliminates the "previous
|
||||
// test left the dashboard in a collapsed/renamed state" class of CI flakes
|
||||
// that bit us repeatedly with `beforeAll`-shared seed: it is no longer
|
||||
// possible for one test's restore PUT to race the next test's GET, because
|
||||
// the next test does not see the previous test's dashboard at all.
|
||||
//
|
||||
// `serial` mode is no longer required for correctness (tests are hermetic)
|
||||
// but we keep parallel runs intra-file because seed creation is the
|
||||
// per-test cost — running them concurrently inside the worker would just
|
||||
// pile up more concurrent dashboards without helping.
|
||||
let apmDashboardId: string;
|
||||
// Sections and the panels inside them, seeded from a spec trimmed out of a real V2
|
||||
// dashboard: three grid sections, six panels across six plugin kinds. Assertions are
|
||||
// structural — titles, membership, collapse — never chart values, so nothing here
|
||||
// depends on the stack holding telemetry.
|
||||
|
||||
test.beforeEach(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
test.use({ viewport: WIDE_VIEWPORT });
|
||||
|
||||
test.afterEach(async ({ browser }) => {
|
||||
if (!apmDashboardId) {
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
const SECTIONS = [
|
||||
{ title: 'Query Builder', firstPanel: 'p-timeseries' },
|
||||
{ title: 'PromQL', firstPanel: 'p-promql' },
|
||||
{ title: 'Mixed', firstPanel: 'p-pie' },
|
||||
];
|
||||
const PANEL_TITLES = [
|
||||
'Requests over time',
|
||||
'Requests by pod',
|
||||
'Duration rate (PromQL)',
|
||||
'Total requests',
|
||||
'Split by environment',
|
||||
'Stacked by pod',
|
||||
];
|
||||
|
||||
async function seedAndOpen(page: Page, label: string): Promise<string> {
|
||||
const id = await createDashboardV2ViaApi(
|
||||
page,
|
||||
`detail-sections-${label}-${process.env.TEST_WORKER_INDEX ?? '0'}`,
|
||||
sectionsFixture.spec,
|
||||
);
|
||||
seedIds.add(id);
|
||||
await page.goto(dashboardV2Path(id));
|
||||
await expect(variablesBar(page)).toBeVisible();
|
||||
return id;
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
await deleteDashboardViaApi(ctx.request, apmDashboardId, token);
|
||||
} catch {
|
||||
// Best-effort cleanup — a failing delete should not mask test
|
||||
// failures the user actually needs to see.
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardV2ViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
apmDashboardId = '';
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve the `.row-panel` container for a section by traversing up from its
|
||||
* title text. The fixture ships two sections both literally named "Overview"
|
||||
* — pass `index` to disambiguate. Two `..` hops reach `.row-panel`, which
|
||||
* holds both the chevron and the settings-icon for that row.
|
||||
*/
|
||||
function sectionRow(
|
||||
page: Page,
|
||||
name: string | RegExp,
|
||||
index = 0,
|
||||
): ReturnType<Page['locator']> {
|
||||
return page
|
||||
.getByText(name, { exact: typeof name === 'string' })
|
||||
.nth(index)
|
||||
.locator('..')
|
||||
.locator('..');
|
||||
}
|
||||
test.describe('Dashboard detail — sections and panels', () => {
|
||||
test('TC-01 every section in the spec renders with its title', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await seedAndOpen(page, 'render');
|
||||
|
||||
async function gotoApmDashboard(page: Page): Promise<void> {
|
||||
await page.goto(`/dashboard/${apmDashboardId}`);
|
||||
await page
|
||||
.getByRole('button', { name: /dashboard-icon APM Metrics/ })
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
// `GridCardLayout`'s auto-save `useEffect` (line 226 of the source) is
|
||||
// gated on `!isDashboardFetching` but `isDashboardFetching` is NOT in the
|
||||
// effect's dep array. Concretely: if a chevron is clicked while any
|
||||
// `[REACT_QUERY_KEY.DASHBOARD_BY_ID]` query is in flight, the effect runs
|
||||
// once for the new `dashboardLayout`, sees `isDashboardFetching=true`, and
|
||||
// returns early — and never re-runs when the GET later completes, because
|
||||
// `dashboardLayout` didn't change again. The PUT is *never* fired and
|
||||
// `toggleSectionAndWaitForPut` blocks until the 30 s test timeout.
|
||||
//
|
||||
// Wait until the in-flight dashboard GETs settle so the effect's gate
|
||||
// evaluates to `false` on the next click. We assert this two ways: a panel
|
||||
// from each visible section must render (proves data is hydrated), and
|
||||
// `Latency` (the first panel of the first Overview section) must paint.
|
||||
await expect(page.getByText('Latency', { exact: true }).first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click `.row-icon` (chevron) on a section row. The collapse/expand state is
|
||||
* driven by React local state — `setDashboardLayout` updates synchronously
|
||||
* and the (suffixed / unsuffixed) title appears on the next render. We do
|
||||
* NOT wait for the auto-save PUT here: it's gated on `!isDashboardFetching`
|
||||
* in `GridCardLayout.tsx` and can be skipped entirely under CI load.
|
||||
* Persistence does not matter because each test seeds a fresh dashboard.
|
||||
*
|
||||
* `dispatchEvent('click')` — under CI viewport the expanded sidenav's
|
||||
* `nav-item-data` subtree intercepts pointer events at the chevron's
|
||||
* position (verified in CI run #26162502354). `.click({ force: true })`
|
||||
* still lands the event at the visual centre and is swallowed by the
|
||||
* overlay; dispatching the click directly on the SVG node bypasses hit
|
||||
* testing entirely and triggers React's `onClick` handler.
|
||||
*/
|
||||
async function toggleSection(row: ReturnType<Page['locator']>): Promise<void> {
|
||||
const chevron = row.locator('.row-icon');
|
||||
await chevron.scrollIntoViewIfNeeded();
|
||||
await expect(chevron).toBeVisible();
|
||||
|
||||
const page = chevron.page();
|
||||
// Register a PUT listener BEFORE the click. The auto-save effect in
|
||||
// `GridCardLayout` fires a PUT when `!isDashboardFetching` — if the PUT
|
||||
// arrives, its `onSuccess` triggers a brief loading-state re-render that
|
||||
// unmounts every `.row-panel`. The next toggle's chevron lookup either
|
||||
// misses (locator times out) or grabs a transient node that detaches
|
||||
// during scroll. Sequencing: dispatch click → await PUT (3 s short
|
||||
// timeout in case auto-save was gated) → wait for the loading spinner
|
||||
// to be absent.
|
||||
const putSettled = page
|
||||
.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
{ timeout: 3_000 },
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
await chevron.dispatchEvent('click');
|
||||
await putSettled;
|
||||
await expect(page.getByAltText('loading')).toHaveCount(0, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
// Poll a section to the target collapsed/expanded state, re-clicking if a
|
||||
// toggle is dropped under CI load. `name` has no regex metacharacters.
|
||||
async function setSectionCollapsed(
|
||||
page: Page,
|
||||
name: string,
|
||||
collapsed: boolean,
|
||||
): Promise<void> {
|
||||
const collapsedTitle = new RegExp(`^${name} \\(\\d+ widgets?\\)$`);
|
||||
await expect(async () => {
|
||||
const alreadyCollapsed = (await page.getByText(collapsedTitle).count()) > 0;
|
||||
if (alreadyCollapsed === collapsed) {
|
||||
return;
|
||||
for (const { title, firstPanel } of SECTIONS) {
|
||||
await expect(section(page, firstPanel)).toBeVisible();
|
||||
await expect(section(page, firstPanel)).toContainText(title);
|
||||
}
|
||||
await toggleSection(
|
||||
sectionRow(page, alreadyCollapsed ? collapsedTitle : name),
|
||||
);
|
||||
expect((await page.getByText(collapsedTitle).count()) > 0).toBe(collapsed);
|
||||
}).toPass({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the settings (⋮) icon on a section header, bypassing the sidenav's
|
||||
* pointer-event interception via `dispatchEvent('click')` (same root cause
|
||||
* as `toggleSectionAndWaitForPut`). The settings popover (Rename / New Panel
|
||||
* / Remove Section) lives on the LEFT of the row at the same x-coordinate
|
||||
* as the chevron, so it suffers the same overlap.
|
||||
*/
|
||||
async function clickSectionSettings(
|
||||
row: ReturnType<Page['locator']>,
|
||||
): Promise<void> {
|
||||
const icon = row.locator('.settings-icon');
|
||||
await icon.scrollIntoViewIfNeeded();
|
||||
await expect(icon).toBeVisible();
|
||||
await icon.dispatchEvent('click');
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Sections', () => {
|
||||
// ─── Collapse / expand chevron and widget-count suffix ───────────────────
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Passes locally
|
||||
// (including `STRESS=1 CI=1`) but flakes on GitHub Linux runner — the
|
||||
// chevron click intermittently fails to land its auto-save PUT despite
|
||||
// `dispatchEvent('click')` + `Latency` panel hydration gate. Suspect
|
||||
// remaining race lives in `GridCardLayout`'s auto-save `useEffect` not
|
||||
// listing `isDashboardFetching` in its deps. See CI-HARDENING.md item 5.
|
||||
test.skip('TC-01 collapsing a section hides panels and shows widget count', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// "DB Metrics" is the third section in the APM fixture and lives below
|
||||
// the fold on the 1280×720 CI viewport. Scroll its title into view and
|
||||
// wait for visibility so the 14×14 chevron is actionable.
|
||||
const dbMetricsTitle = page.getByText('DB Metrics', { exact: true }).first();
|
||||
await dbMetricsTitle.scrollIntoViewIfNeeded();
|
||||
await expect(dbMetricsTitle).toBeVisible();
|
||||
await toggleSection(sectionRow(page, 'DB Metrics'));
|
||||
|
||||
// After collapse the section title is rewritten to include the count
|
||||
// suffix; assert with a regex so the test is robust to widget-count
|
||||
// drift in the fixture.
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore: chevron-down is the row-icon variant rendered for collapsed
|
||||
// sections. Re-resolve via the new (suffixed) title.
|
||||
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
|
||||
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 widget count matches number of panels visible before collapse', async ({
|
||||
test('TC-02 every panel in the spec renders with its title', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
await seedAndOpen(page, 'panels');
|
||||
|
||||
// The first Overview section in the APM fixture holds these four
|
||||
// panels — they're our ground truth for the count assertion below.
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Request rate', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Error percentage', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Top operations', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await toggleSection(sectionRow(page, 'Overview', 0));
|
||||
|
||||
await expect(
|
||||
page.getByText('Overview (4 widgets)', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await toggleSection(sectionRow(page, 'Overview (4 widgets)'));
|
||||
await expect(
|
||||
page.getByText('Overview (4 widgets)', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
for (const title of PANEL_TITLES) {
|
||||
await expect(panelByTitle(page, title).first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-03 expanding restores panels', async ({ authedPage: page }) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// Collapse "DB Metrics" instead of the first Overview — its widgets
|
||||
// have unique titles ("DB Calls RPS" / "Database Calls Avg Duration")
|
||||
// so collapse/expand transitions can be asserted without colliding
|
||||
// with the duplicate-titled panels in the two Overview sections.
|
||||
// "DB Metrics" lives further down the canvas; scroll into view first
|
||||
// so the panels actually mount (the canvas virtualises off-screen).
|
||||
const dbCalls = page.getByText('DB Calls RPS', { exact: true }).first();
|
||||
await dbCalls.scrollIntoViewIfNeeded();
|
||||
await expect(dbCalls).toBeVisible({ timeout: 15_000 });
|
||||
await toggleSection(sectionRow(page, 'DB Metrics'));
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// While collapsed, "DB Calls RPS" should fully unmount.
|
||||
await expect(page.getByText('DB Calls RPS', { exact: true })).toHaveCount(0);
|
||||
|
||||
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
|
||||
|
||||
await expect(
|
||||
page.getByText('DB Calls RPS', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ─── Section options menu (Rename / New Panel / Remove Section) ──────────
|
||||
|
||||
test('TC-04 section options menu shows Rename / New Panel / Remove Section', async ({
|
||||
test('TC-03 a panel belongs to the section that references it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
await seedAndOpen(page, 'membership');
|
||||
|
||||
// Use DB Metrics — its settings popover is guaranteed to render all
|
||||
// three buttons when the section is expanded. WidgetRow.tsx hides
|
||||
// "Remove Section" while a section is collapsed.
|
||||
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
|
||||
|
||||
const tooltip = page.getByRole('tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip.getByRole('button', { name: 'Rename' })).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'New Panel', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'Remove Section' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
// The layout puts these two in "PromQL" and nothing else there.
|
||||
const promql = section(page, 'p-promql');
|
||||
await expect(promql).toContainText('Duration rate (PromQL)');
|
||||
await expect(promql).toContainText('Total requests');
|
||||
await expect(promql).not.toContainText('Requests over time');
|
||||
});
|
||||
|
||||
test('TC-05 rename a section, restore original name', async ({
|
||||
test('TC-04 collapsing a section hides the panels inside it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
await seedAndOpen(page, 'collapse');
|
||||
await expect(panelByTitle(page, 'Requests over time').first()).toBeVisible();
|
||||
|
||||
const renamed = `Renamed Section ${Date.now()}`;
|
||||
await sectionToggle(page, 'p-timeseries').click();
|
||||
|
||||
// DB Metrics has a unique name, avoiding the duplicate-Overview snag.
|
||||
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
|
||||
const renameDialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
await expect(renameDialog).toBeVisible();
|
||||
const nameInput = renameDialog.getByPlaceholder('Enter row name here...');
|
||||
await nameInput.click();
|
||||
await nameInput.fill(renamed);
|
||||
await renameDialog.getByRole('button', { name: 'Apply Changes' }).click();
|
||||
await expect(renameDialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(renamed, { exact: true }).first()).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await clickSectionSettings(sectionRow(page, renamed));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
const restoreDialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
const restoreInput = restoreDialog.getByPlaceholder('Enter row name here...');
|
||||
await restoreInput.click();
|
||||
await restoreInput.fill('DB Metrics');
|
||||
await restoreDialog.getByRole('button', { name: 'Apply Changes' }).click();
|
||||
await expect(restoreDialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(renamed, { exact: true })).toHaveCount(0);
|
||||
await expect(panelByTitle(page, 'Requests over time').first()).toBeHidden();
|
||||
// Its neighbours are untouched.
|
||||
await expect(panelByTitle(page, 'Total requests').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 cancel section rename leaves name unchanged', async ({
|
||||
test('TC-05 expanding a collapsed section brings its panels back', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
await seedAndOpen(page, 'expand');
|
||||
const toggle = sectionToggle(page, 'p-timeseries');
|
||||
|
||||
await clickSectionSettings(sectionRow(page, 'External calls'));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
await toggle.click();
|
||||
await expect(panelByTitle(page, 'Requests over time').first()).toBeHidden();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
await expect(dialog).toBeVisible();
|
||||
const input = dialog.getByPlaceholder('Enter row name here...');
|
||||
await input.click();
|
||||
await input.fill('Should Not Be Applied');
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Should Not Be Applied')).toHaveCount(0);
|
||||
await toggle.click();
|
||||
await expect(panelByTitle(page, 'Requests over time').first()).toBeVisible();
|
||||
});
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Flaky because of hover interaction on menu, will be changing with new implementation with perses.
|
||||
test.skip('TC-07 add a new panel to a section, then delete it', async ({
|
||||
test('TC-06 a panel exposes its actions menu', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
await seedAndOpen(page, 'actions');
|
||||
|
||||
const panelName = `Test Panel ${Date.now()}`;
|
||||
await panelByTitle(page, 'Requests over time').first().hover();
|
||||
await panelActions(page, 'p-timeseries').click();
|
||||
|
||||
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'New Panel', exact: true })
|
||||
.click();
|
||||
|
||||
const panelTypeDialog = page.getByRole('dialog', { name: 'New Panel' });
|
||||
await expect(panelTypeDialog).toBeVisible();
|
||||
await panelTypeDialog.getByTestId('panel-type-graph').click();
|
||||
|
||||
// We're now in the panel editor at /dashboard/:id/new?widgetId=…
|
||||
await page.waitForURL(/\/new/);
|
||||
await page.getByTestId('panel-name-input').fill(panelName);
|
||||
|
||||
// NewWidget renders TWO buttons with `data-testid="new-widget-save"` —
|
||||
// a disabled variant when `isSaveDisabled` is true and an enabled
|
||||
// variant when it is false. Under CI load the editor mounts with the
|
||||
// disabled variant first; without `toBeEnabled` the click can hit the
|
||||
// disabled button and the Save dialog never opens.
|
||||
const saveBtn = page.getByTestId('new-widget-save');
|
||||
await expect(saveBtn).toBeVisible();
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
|
||||
// `dispatchEvent('click')` — sidenav overlap risk on CI; see the same
|
||||
// rationale on `toggleSectionAndWaitForPut` above.
|
||||
await saveBtn.dispatchEvent('click');
|
||||
const saveDialog = page.getByRole('dialog', { name: 'Save Widget' });
|
||||
await expect(saveDialog).toBeVisible();
|
||||
|
||||
// PUT confirms the panel persisted server-side — more reliable than
|
||||
// waiting on redux state to propagate before navigating back.
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await saveDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await putResponse;
|
||||
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(
|
||||
page.getByText(panelName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// The panel ⋮ menu is a Radix `DropdownMenuSimple` — it opens on click,
|
||||
// not hover (see `openPanelMoreMenu` in 21-panel-actions.spec.ts). The
|
||||
// container hover only reveals the kebab (it's `visibility: hidden`
|
||||
// until then); the click toggles the menu. Wait for the menu role to be
|
||||
// visible before clicking Delete.
|
||||
const panelTitle = page.getByText(panelName, { exact: true }).first();
|
||||
await panelTitle.hover();
|
||||
const panelContainer = panelTitle.locator('../..');
|
||||
await panelContainer.scrollIntoViewIfNeeded();
|
||||
await panelContainer.hover();
|
||||
await panelContainer.getByTestId('widget-header-options').click();
|
||||
const menu = page.getByRole('menu');
|
||||
await menu.waitFor({ state: 'visible' });
|
||||
await menu.getByRole('menuitem', { name: 'Delete', exact: true }).click();
|
||||
|
||||
const deleteDialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(deleteDialog).toBeVisible();
|
||||
|
||||
const deletePut = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await deleteDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await deletePut;
|
||||
await expect(deleteDialog).not.toBeVisible();
|
||||
await expect(page.getByText(panelName, { exact: true })).toHaveCount(0);
|
||||
// Assert the affordances the menu offers rather than a container testid: the one
|
||||
// in the source is not rendered on this path, and the items are what users act on.
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
for (const item of ['View', 'Edit panel', 'Clone', 'Delete panel']) {
|
||||
await expect(page.getByRole('menuitem', { name: item })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── New section in edit mode ────────────────────────────────────────────
|
||||
|
||||
test('TC-08 add a new section via edit mode, then remove it', async ({
|
||||
test('TC-07 a panel with nothing to show renders its no-data state, not an error', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
await seedAndOpen(page, 'nodata');
|
||||
|
||||
const sectionName = `Temp Section ${Date.now()}`;
|
||||
|
||||
await page.getByTestId('options').click();
|
||||
await page.getByRole('button', { name: 'New section' }).click();
|
||||
|
||||
const newSectionDialog = page.getByRole('dialog', { name: 'New Section' });
|
||||
await expect(newSectionDialog).toBeVisible();
|
||||
await newSectionDialog.getByTestId('section-name').fill(sectionName);
|
||||
await newSectionDialog
|
||||
.getByRole('button', { name: 'Create Section' })
|
||||
.click();
|
||||
await expect(newSectionDialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(sectionName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await clickSectionSettings(sectionRow(page, sectionName));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Remove Section' })
|
||||
.click();
|
||||
|
||||
const deleteRowDialog = page.getByRole('dialog', { name: 'Delete Row' });
|
||||
await expect(deleteRowDialog).toBeVisible();
|
||||
await deleteRowDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(deleteRowDialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(sectionName, { exact: true })).toHaveCount(0);
|
||||
|
||||
// Original sections are untouched.
|
||||
await expect(
|
||||
page.getByText('Overview', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 collapsing two sections in sequence shows both as collapsed', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
await setSectionCollapsed(page, 'DB Metrics', true);
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await setSectionCollapsed(page, 'External calls', true);
|
||||
await expect(
|
||||
page.getByText(/^External calls \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore both so the test leaves no state behind.
|
||||
await setSectionCollapsed(page, 'DB Metrics', false);
|
||||
await setSectionCollapsed(page, 'External calls', false);
|
||||
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
|
||||
await expect(page.getByText(/^External calls \(\d+ widgets?\)$/)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-10 panels inside a collapsed section are not in the DOM', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// "DB Calls RPS" is a unique panel inside the "DB Metrics" section.
|
||||
const dbPanel = page.getByText('DB Calls RPS', { exact: true });
|
||||
await dbPanel.first().scrollIntoViewIfNeeded();
|
||||
await expect(dbPanel.first()).toBeVisible();
|
||||
|
||||
await toggleSection(sectionRow(page, 'DB Metrics'));
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Panels inside the collapsed section unmount, not just hidden.
|
||||
await expect(dbPanel).toHaveCount(0);
|
||||
|
||||
// Restore.
|
||||
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
|
||||
await expect(dbPanel.first()).toBeVisible();
|
||||
// The seeded queries target signals this stack holds nothing for, so the panels
|
||||
// resolve empty — that must read as "no data", never as a failure.
|
||||
await expect(page.getByTestId('panel-no-data').first()).toBeVisible();
|
||||
await expect(page.getByTestId('panel-error')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -1,623 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
add_license,
|
||||
change_user_role,
|
||||
create_active_user,
|
||||
find_user_by_email,
|
||||
)
|
||||
from fixtures.role import transaction_group
|
||||
from fixtures.types import Operation, SigNoz, TestContainerDocker
|
||||
|
||||
V2_BASE_URL = "/api/v2/dashboards"
|
||||
MAX_LIST_LIMIT = 200
|
||||
|
||||
_EDITOR_EMAIL = "editor+dashboardauthz@integration.test"
|
||||
_EDITOR_PASSWORD = "password123Z$"
|
||||
_VIEWER_EMAIL = "viewer+dashboardauthz@integration.test"
|
||||
_VIEWER_PASSWORD = "password123Z$"
|
||||
|
||||
_ACTOR_ROLE_NAME = "dashboard-fga-actor"
|
||||
_ACTOR_EMAIL = "customrole+dashboardauthz@integration.test"
|
||||
_ACTOR_PASSWORD = "password123Z$"
|
||||
|
||||
# Instance verbs are granted on _TARGET_A only; _TARGET_B must stay forbidden.
|
||||
_TARGET_A = "dashboard-authz-target-a"
|
||||
_TARGET_B = "dashboard-authz-target-b"
|
||||
_CLONE_SOURCE = "dashboard-authz-clone-source"
|
||||
_VIEW_NAME = "dashboard-authz-view"
|
||||
|
||||
_SPEC = {
|
||||
"display": {"name": "Dashboard Authz"},
|
||||
"duration": "1h",
|
||||
"links": [],
|
||||
"variables": [],
|
||||
"panels": {},
|
||||
"layouts": [],
|
||||
}
|
||||
|
||||
|
||||
# ─── managed roles (community-safe: no license, role gate only) ───────────────
|
||||
|
||||
|
||||
def test_setup_managed_role_users(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# A rerun against a --reuse stack starts from the previous run's state, and
|
||||
# inviting an existing address fails, so only invite what is missing.
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
existing_emails = {user["email"] for user in response.json()["data"]}
|
||||
|
||||
for email, role, password, name in (
|
||||
(_EDITOR_EMAIL, "EDITOR", _EDITOR_PASSWORD, "dashboard authz editor"),
|
||||
(_VIEWER_EMAIL, "VIEWER", _VIEWER_PASSWORD, "dashboard authz viewer"),
|
||||
):
|
||||
if email not in existing_emails:
|
||||
create_active_user(signoz, admin_token, email=email, role=role, password=password, name=name)
|
||||
|
||||
# (org_id, name) is unique, so leftovers from an earlier run have to go before
|
||||
# these are recreated.
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
for dashboard in response.json()["data"]["dashboards"]:
|
||||
if dashboard["name"] in (_TARGET_A, _TARGET_B, _CLONE_SOURCE):
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{dashboard['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
for name in (_TARGET_A, _TARGET_B, _CLONE_SOURCE):
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"schemaVersion": "v6", "name": name, "spec": _SPEC, "tags": []},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
|
||||
def test_viewer_allowed_on_reads_and_pins(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_TARGET_A]}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[_TARGET_A]}/pins"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[_TARGET_A]}/pins"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_viewer_forbidden_on_mutations(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
target_id = ids[_TARGET_A]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"schemaVersion": "v6", "name": "viewer-denied", "spec": _SPEC, "tags": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"create: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}"),
|
||||
json={"schemaVersion": "v6", "name": _TARGET_A, "spec": _SPEC, "tags": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"update: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}"),
|
||||
json=[{"op": "replace", "path": "/spec/display/name", "value": "Patched"}],
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"patch: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}/clone"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"clone: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}/migrate"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"migrate: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}/lock"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"lock: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"delete: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
def test_editor_allowed_on_mutations(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_TARGET_A]}"),
|
||||
json={"schemaVersion": "v6", "name": _TARGET_A, "spec": _SPEC, "tags": [{"key": "team", "value": "pulse"}]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_CLONE_SOURCE]}/clone"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
clone_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{clone_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_viewer_forbidden_on_public_config(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
# The public config routes authorize on the dashboard itself, so a viewer is
|
||||
# refused before the handler runs, licensed or not.
|
||||
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
target_id = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}[_TARGET_A]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"create public: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
json={"timeRangeEnabled": False, "defaultTimeRange": "10m"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"update public: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"delete public: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
def test_viewer_allowed_on_saved_views(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
# Saved views ride on dashboard list, which a viewer holds, so the whole CRUD
|
||||
# surface is open to them.
|
||||
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/dashboard_views"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/dashboard_views"),
|
||||
json={"name": _VIEW_NAME, "data": {"version": "v1", "sort": "updated_at", "order": "desc"}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
view_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/dashboard_views/{view_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
# ─── per-object FGA scoping (enterprise, custom role) ─────────────────────────
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
def test_editor_allowed_on_public_config(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
# An editor holds dashboard update, which is what the public config routes ask
|
||||
# for, so publishing is open to editors and not just admins.
|
||||
token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
target_id = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}[_TARGET_B]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
# Reading the config asks only for dashboard read, so a viewer sees it too.
|
||||
viewer_token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_setup_scoped_actor(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_role: Callable[..., str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
|
||||
create_role(
|
||||
admin_token,
|
||||
_ACTOR_ROLE_NAME,
|
||||
[
|
||||
transaction_group("read", "metaresource", "dashboard", [ids[_TARGET_A]]),
|
||||
transaction_group("list", "metaresource", "dashboard", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
user_id = create_active_user(signoz, admin_token, email=_ACTOR_EMAIL, role="VIEWER", password=_ACTOR_PASSWORD, name="dashboard fga actor")
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", _ACTOR_ROLE_NAME)
|
||||
|
||||
|
||||
def test_read_scoped_to_granted_dashboard(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_TARGET_A]}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"read granted dashboard: {response.text}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_TARGET_B]}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"read other dashboard: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
def test_list_returns_every_dashboard(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
|
||||
# list is collection-scoped: list on "*" returns every dashboard, including
|
||||
# the one the actor cannot read individually.
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
names = {dashboard["name"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
assert {_TARGET_A, _TARGET_B} <= names
|
||||
|
||||
|
||||
def test_publish_and_unpublish_require_update_on_the_dashboard(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
actor_role_id = find_role_id(admin_token, _ACTOR_ROLE_NAME)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
target_id = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}[_TARGET_A]
|
||||
|
||||
# read and list alone must not be enough to publish.
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [
|
||||
transaction_group("read", "metaresource", "dashboard", [target_id]),
|
||||
transaction_group("list", "metaresource", "dashboard", ["*"]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"publish without dashboard update: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [
|
||||
transaction_group("read", "metaresource", "dashboard", [target_id]),
|
||||
transaction_group("list", "metaresource", "dashboard", ["*"]),
|
||||
transaction_group("update", "metaresource", "dashboard", [target_id]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, f"publish with dashboard update: {response.text}"
|
||||
|
||||
# Unpublishing asks for the same update grant.
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [
|
||||
transaction_group("read", "metaresource", "dashboard", [target_id]),
|
||||
transaction_group("list", "metaresource", "dashboard", ["*"]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"unpublish without dashboard update: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [
|
||||
transaction_group("read", "metaresource", "dashboard", [target_id]),
|
||||
transaction_group("list", "metaresource", "dashboard", ["*"]),
|
||||
transaction_group("update", "metaresource", "dashboard", [target_id]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"unpublish with dashboard update: {response.text}"
|
||||
|
||||
|
||||
def test_dashboard_authz_cleanup(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
actor = find_user_by_email(signoz, admin_token, _ACTOR_EMAIL)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{actor['id']}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
actor_entry = next((role for role in response.json()["data"] if role["name"] == _ACTOR_ROLE_NAME), None)
|
||||
if actor_entry is not None:
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{actor['id']}/roles/{actor_entry['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"remove role from user: {response.text}"
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_id(admin_token, _ACTOR_ROLE_NAME)}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"delete {_ACTOR_ROLE_NAME}: {response.text}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
|
||||
for name in (_TARGET_A, _TARGET_B, _CLONE_SOURCE):
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[name]}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"delete {name}: {response.text}"
|
||||